diff --git a/static/app.js b/static/app.js
index 3b91922..e25fda4 100644
--- a/static/app.js
+++ b/static/app.js
@@ -133,6 +133,12 @@ createApp({
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
+ const drawingColors = [
+ '#65b7cf', '#27825c', '#45c39b', '#a8c85d',
+ '#efb643', '#e0a34a', '#d97a3a', '#bd4545',
+ '#d96073', '#b07ad6', '#7d67c9', '#4f6fae',
+ '#9fb0c4', '#746c60', '#2c2924', '#c8992f',
+ ];
const now = ref(Date.now());
let chartApi = null;
let socket = null;
@@ -647,6 +653,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
- return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
+ return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');
diff --git a/static/index.html b/static/index.html
index 8cda64e..94bba7d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -133,35 +133,75 @@
@click.stop @change="toggleLineSelection(item.id)">
-
-
+
diff --git a/static/style.css b/static/style.css
index b36e640..4d80dd2 100644
--- a/static/style.css
+++ b/static/style.css
@@ -18,7 +18,11 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.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; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
-.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
+.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
+.trendline-row { display:grid; grid-template-columns: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:91px; 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; }.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; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
@@ -48,8 +52,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
-.armed-toggle { display:flex; align-items:center; gap:4px; font-size:10px; color:var(--accent); cursor:pointer; }
-.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
+.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
@@ -72,8 +75,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
-.collapse-toggle { font:inherit; font-size:9px; padding:2px 6px; border:1px solid var(--line);
- border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; }
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs
index ce3aa3b..83f3a24 100644
--- a/tests/e2e/drawings.test.mjs
+++ b/tests/e2e/drawings.test.mjs
@@ -35,6 +35,20 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
await page.fill('input[aria-label="Filter drawings by text"]', label);
const input = page.locator('input[aria-label="Drawing name"]');
assert.equal(await input.count(), 1, 'the unique drawing filter did not isolate the test line');
+ const row = page.locator('.trendline-row');
+ const rowBox = await row.boundingBox();
+ assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`);
+ assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
+ 'the armed state is not represented by a bell');
+ await row.locator('.color-picker>summary').click();
+ assert.equal(await row.locator('.color-popover>button').count(), 16,
+ 'the preset palette does not contain 16 colors');
+ assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
+ 'the custom color choice is missing');
+ await row.locator('button[aria-label="Use color #bd4545"]').click();
+ await page.waitForFunction(([id, color]) =>
+ window.__chart.levels.find(level => level.id === id)?.color === color,
+ [created, '#bd4545']);
await input.focus();
await input.press('End');
await input.press('Backspace');
diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs
index 7ccb220..ea08e57 100644
--- a/tests/e2e/trendline.test.mjs
+++ b/tests/e2e/trendline.test.mjs
@@ -117,6 +117,8 @@ test('the snapped extreme decides the side, overriding the dropdown', { timeout:
test('dragging an anchor uses the same bar-extreme snapping as placement',
{ timeout: 180000 }, async () => {
await withChart(async page => {
+ const beforeNumber = await page.evaluate(() => Math.max(0, ...window.__chart.levels
+ .filter(level => level.kind === 'manual').map(level => level.number)));
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.30, 0.55);
@@ -124,8 +126,12 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
- await page.waitForFunction(() =>
- document.querySelectorAll('.chart-anchor:not([hidden])').length === 2);
+ await page.waitForFunction(number => {
+ const c = window.__chart;
+ const selected = c.levels.find(level => level.id === c.selectedLineId);
+ return selected?.number > number
+ && document.querySelectorAll('.chart-anchor:not([hidden])').length === 2;
+ }, beforeNumber);
const target = await page.evaluate(() => {
const c = window.__chart;
@@ -188,7 +194,7 @@ test('future whitespace accepts endpoint and whole-line movement without enterin
return {
id: line.id,
x: plot.left + c.coordinateAtIndex(index),
- y: plot.top + c.candles.priceToCoordinate(c.linePriceAtIndex(line, index)),
+ y: plot.top + plot.height * 0.45,
};
});
const endHandle = await page.locator(
@@ -280,9 +286,11 @@ test('the line context menu duplicates by ten bars and deletes through the norma
{ button: 'right' });
await page.locator('.chart-context-menu [data-action="duplicate"]').click();
await page.waitForFunction(number => {
- const lines = window.__chart.levels.filter(level =>
+ const c = window.__chart;
+ const lines = c.levels.filter(level =>
level.kind === 'manual' && level.slope !== 0);
- return lines.some(level => level.number > number);
+ const duplicate = lines.find(level => level.number > number);
+ return duplicate && c.selectedLineId === duplicate.id;
}, original.number);
const duplicated = await page.evaluate(originalLine => {
@@ -297,6 +305,8 @@ test('the line context menu duplicates by ten bars and deletes through the norma
line,
anchorShift: nearestIndex(line.anchor_t) - nearestIndex(original.anchor_t),
endShift: nearestIndex(line.last_t) - nearestIndex(original.last_t),
+ originalTitle: c.levelSeries.get(original.id).series.options().title,
+ duplicateTitle: c.levelSeries.get(line.id).series.options().title,
};
}, original);
assert.equal(duplicated.anchorShift, 10);
@@ -304,6 +314,8 @@ test('the line context menu duplicates by ten bars and deletes through the norma
const expectedName = `${original.side === 'support' ? 'up' : 'down'}${original.tf}`;
assert.equal(original.label, expectedName);
assert.equal(duplicated.line.label, `${expectedName} 2`);
+ assert.equal(duplicated.originalTitle, `#${original.number}`);
+ assert.equal(duplicated.duplicateTitle, duplicated.line.label);
assert.equal(duplicated.line.color, original.color);
assert.equal(duplicated.line.line_width, original.line_width);