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)"> - - +
+
+ + + + + +
- #{{ item.number }} · comment · {{ item.comment.pinned ? 'pinned' : 'floating' }} - #{{ item.number }} · level {{ item.line.anchor_p.toFixed(2) }} - #{{ item.number }} · {{ item.line.tf }} · {{ item.line.side }} +
+ #{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }} + #{{ item.number }} · L {{ item.line.anchor_p.toFixed(2) }} + #{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }} - - - - - -
- - -
-
- - +
+
+ +
+ + +
+
+ +
+
+
+ +
+ + +
+
+ +
+
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);