respositionable trendlines
This commit is contained in:
parent
6599c3cd77
commit
512e94237a
4 changed files with 130 additions and 1 deletions
|
|
@ -30,6 +30,10 @@ class LinePatch(BaseModel):
|
||||||
hidden: bool | None = None
|
hidden: bool | None = None
|
||||||
color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
line_width: int | None = Field(None, ge=1, le=4)
|
line_width: int | None = Field(None, ge=1, le=4)
|
||||||
|
anchor_t: int | None = None
|
||||||
|
anchor_p: float | None = None
|
||||||
|
slope: float | None = None
|
||||||
|
last_t: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
|
|
@ -109,6 +113,8 @@ def create_line(request: Request, payload: LineCreate):
|
||||||
@router.patch("/lines/{line_id}")
|
@router.patch("/lines/{line_id}")
|
||||||
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
changes = payload.model_dump(exclude_none=True)
|
changes = payload.model_dump(exclude_none=True)
|
||||||
|
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
|
||||||
|
raise HTTPException(400, "Line endpoints must have different times")
|
||||||
try:
|
try:
|
||||||
line = request.app.state.runtime.manual_lines.update(line_id, changes)
|
line = request.app.state.runtime.manual_lines.update(line_id, changes)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,20 @@ createApp({
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateLineGeometry(line) {
|
||||||
|
const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
anchor_t: line.anchor_t, anchor_p: line.anchor_p,
|
||||||
|
slope: line.slope, last_t: line.last_t,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
|
||||||
|
const saved = await response.json();
|
||||||
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||||
|
syncVisibleLevels();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event) {
|
||||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
|
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -255,11 +269,16 @@ createApp({
|
||||||
sendPrefs();
|
sendPrefs();
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch(selectedLine, id => {
|
||||||
|
if (chartApi) chartApi.setSelectedLine(id);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chartApi = new ConfluenceChart();
|
chartApi = new ConfluenceChart();
|
||||||
chartApi.create(document.getElementById('chart'));
|
chartApi.create(document.getElementById('chart'));
|
||||||
chartApi.setClickHandler(handleChartClick);
|
chartApi.setClickHandler(handleChartClick);
|
||||||
chartApi.setMoveHandler(handleChartMove);
|
chartApi.setMoveHandler(handleChartMove);
|
||||||
|
chartApi.setLineChangeHandler(updateLineGeometry);
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
connect();
|
connect();
|
||||||
|
|
|
||||||
104
static/chart.js
104
static/chart.js
|
|
@ -12,6 +12,12 @@ class ConfluenceChart {
|
||||||
this.tooltip = null;
|
this.tooltip = null;
|
||||||
this.chartEl = null;
|
this.chartEl = null;
|
||||||
this.clickListener = null;
|
this.clickListener = null;
|
||||||
|
this.selectedLineId = null;
|
||||||
|
this.anchorHandles = [];
|
||||||
|
this.draggingAnchor = null;
|
||||||
|
this.onLineChange = null;
|
||||||
|
this.anchorMoveListener = null;
|
||||||
|
this.anchorUpListener = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(el) {
|
create(el) {
|
||||||
|
|
@ -36,6 +42,7 @@ class ConfluenceChart {
|
||||||
});
|
});
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
||||||
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
||||||
});
|
});
|
||||||
this.resizeObserver.observe(el);
|
this.resizeObserver.observe(el);
|
||||||
this.tooltip = document.createElement('div');
|
this.tooltip = document.createElement('div');
|
||||||
|
|
@ -48,6 +55,25 @@ class ConfluenceChart {
|
||||||
this.previewLine.setAttribute('hidden', '');
|
this.previewLine.setAttribute('hidden', '');
|
||||||
preview.appendChild(this.previewLine);
|
preview.appendChild(this.previewLine);
|
||||||
el.appendChild(preview);
|
el.appendChild(preview);
|
||||||
|
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
handles.classList.add('chart-handles');
|
||||||
|
handles.setAttribute('aria-hidden', 'true');
|
||||||
|
for (const anchor of ['start', 'end']) {
|
||||||
|
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
handle.classList.add('chart-anchor');
|
||||||
|
handle.dataset.anchor = anchor;
|
||||||
|
handle.setAttribute('r', '6');
|
||||||
|
handle.setAttribute('hidden', '');
|
||||||
|
handle.addEventListener('pointerdown', event => this.startAnchorDrag(event, anchor));
|
||||||
|
handle.addEventListener('click', event => event.stopPropagation());
|
||||||
|
handles.appendChild(handle);
|
||||||
|
this.anchorHandles.push(handle);
|
||||||
|
}
|
||||||
|
el.appendChild(handles);
|
||||||
|
this.anchorMoveListener = event => this.moveAnchor(event);
|
||||||
|
this.anchorUpListener = event => this.finishAnchorDrag(event);
|
||||||
|
window.addEventListener('pointermove', this.anchorMoveListener);
|
||||||
|
window.addEventListener('pointerup', this.anchorUpListener);
|
||||||
this.clickListener = event => {
|
this.clickListener = event => {
|
||||||
if (!this.onChartClick) return;
|
if (!this.onChartClick) return;
|
||||||
const bounds = el.getBoundingClientRect();
|
const bounds = el.getBoundingClientRect();
|
||||||
|
|
@ -64,6 +90,7 @@ class ConfluenceChart {
|
||||||
this.updateLineTooltip(param);
|
this.updateLineTooltip(param);
|
||||||
if (this.onChartMove) this.onChartMove(param);
|
if (this.onChartMove) this.onChartMove(param);
|
||||||
});
|
});
|
||||||
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
|
||||||
}
|
}
|
||||||
|
|
||||||
setBars(bars) {
|
setBars(bars) {
|
||||||
|
|
@ -73,12 +100,14 @@ class ConfluenceChart {
|
||||||
from: Math.max(0, bars.length - 160),
|
from: Math.max(0, bars.length - 160),
|
||||||
to: bars.length + 5,
|
to: bars.length + 5,
|
||||||
});
|
});
|
||||||
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBar(bar) {
|
updateBar(bar) {
|
||||||
this.candles.update(this.toCandle(bar));
|
this.candles.update(this.toCandle(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);
|
||||||
|
this.renderAnchorHandles();
|
||||||
}
|
}
|
||||||
|
|
||||||
syncLevels(levels) {
|
syncLevels(levels) {
|
||||||
|
|
@ -119,12 +148,20 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
entry.series.setData(data);
|
entry.series.setData(data);
|
||||||
}
|
}
|
||||||
|
this.renderAnchorHandles();
|
||||||
}
|
}
|
||||||
|
|
||||||
setClickHandler(handler) { this.onChartClick = handler; }
|
setClickHandler(handler) { this.onChartClick = handler; }
|
||||||
|
|
||||||
setMoveHandler(handler) { this.onChartMove = handler; }
|
setMoveHandler(handler) { this.onChartMove = handler; }
|
||||||
|
|
||||||
|
setLineChangeHandler(handler) { this.onLineChange = handler; }
|
||||||
|
|
||||||
|
setSelectedLine(id) {
|
||||||
|
this.selectedLineId = id;
|
||||||
|
this.renderAnchorHandles();
|
||||||
|
}
|
||||||
|
|
||||||
setLinePreview(start, end, color, lineWidth) {
|
setLinePreview(start, end, color, lineWidth) {
|
||||||
if (start.t === end.t) return;
|
if (start.t === end.t) return;
|
||||||
const x1 = this.chart.timeScale().timeToCoordinate(start.t);
|
const x1 = this.chart.timeScale().timeToCoordinate(start.t);
|
||||||
|
|
@ -208,6 +245,71 @@ class ConfluenceChart {
|
||||||
return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time);
|
return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderAnchorHandles() {
|
||||||
|
const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual');
|
||||||
|
if (!level || !this.anchorHandles.length) {
|
||||||
|
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const points = this.lineData(level).slice(0, 2);
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const x = this.chart.timeScale().timeToCoordinate(point.time);
|
||||||
|
const y = this.candles.priceToCoordinate(point.value);
|
||||||
|
const handle = this.anchorHandles[index];
|
||||||
|
if (x == null || y == null) {
|
||||||
|
handle.setAttribute('hidden', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.removeAttribute('hidden');
|
||||||
|
handle.setAttribute('cx', x);
|
||||||
|
handle.setAttribute('cy', y);
|
||||||
|
handle.setAttribute('fill', level.color || ConfluenceChart.tfColors[level.tf]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startAnchorDrag(event, anchor) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.draggingAnchor = { id: this.selectedLineId, anchor };
|
||||||
|
}
|
||||||
|
|
||||||
|
moveAnchor(event) {
|
||||||
|
if (!this.draggingAnchor || !this.chartEl) return;
|
||||||
|
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
|
||||||
|
if (!level || !this.bars.length) return;
|
||||||
|
const bounds = this.chartEl.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left));
|
||||||
|
const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top));
|
||||||
|
const rawTime = this.chart.timeScale().coordinateToTime(x);
|
||||||
|
const price = this.candles.coordinateToPrice(y);
|
||||||
|
if (rawTime == null || price == null) return;
|
||||||
|
const time = this.bars.reduce((nearest, bar) =>
|
||||||
|
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest
|
||||||
|
).t;
|
||||||
|
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
||||||
|
if (this.draggingAnchor.anchor === 'start') {
|
||||||
|
if (time >= level.last_t) return;
|
||||||
|
level.anchor_t = time;
|
||||||
|
level.anchor_p = price;
|
||||||
|
level.slope = (secondPrice - price) / (level.last_t - time);
|
||||||
|
} else {
|
||||||
|
if (time <= level.anchor_t) return;
|
||||||
|
level.last_t = time;
|
||||||
|
level.slope = (price - level.anchor_p) / (time - level.anchor_t);
|
||||||
|
}
|
||||||
|
const entry = this.levelSeries.get(level.id);
|
||||||
|
if (entry) entry.series.setData(this.lineData(level));
|
||||||
|
this.renderAnchorHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
finishAnchorDrag(event) {
|
||||||
|
if (!this.draggingAnchor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
|
||||||
|
this.draggingAnchor = null;
|
||||||
|
if (level && this.onLineChange) this.onLineChange({ ...level });
|
||||||
|
}
|
||||||
|
|
||||||
toCandle(bar) {
|
toCandle(bar) {
|
||||||
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +317,8 @@ class ConfluenceChart {
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||||
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
|
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
|
||||||
|
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||||
|
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
||||||
if (this.chart) this.chart.remove();
|
if (this.chart) this.chart.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
||||||
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 { position:absolute; z-index:4; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview line[hidden] { display:none; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display: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; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||||
.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; }
|
.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; }
|
||||||
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 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 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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue