745 lines
26 KiB
JavaScript
745 lines
26 KiB
JavaScript
const state = {
|
|
cases: [],
|
|
recommendations: [],
|
|
attemptHistory: [],
|
|
curriculum: null,
|
|
selectedCase: null,
|
|
attempt: null,
|
|
replay: null,
|
|
chartModel: null,
|
|
selectedCandleIndex: null,
|
|
};
|
|
|
|
const el = {
|
|
instrumentId: document.getElementById('instrumentId'),
|
|
caseType: document.getElementById('caseType'),
|
|
caseLimit: document.getElementById('caseLimit'),
|
|
loadCasesBtn: document.getElementById('loadCasesBtn'),
|
|
generateCasesBtn: document.getElementById('generateCasesBtn'),
|
|
caseList: document.getElementById('caseList'),
|
|
progressPanel: document.getElementById('progressPanel'),
|
|
curriculumPanel: document.getElementById('curriculumPanel'),
|
|
caseTitle: document.getElementById('caseTitle'),
|
|
frameMeta: document.getElementById('frameMeta'),
|
|
sessionMeta: document.getElementById('sessionMeta'),
|
|
chart: document.getElementById('replayChart'),
|
|
taskList: document.getElementById('taskList'),
|
|
scorePanel: document.getElementById('scorePanel'),
|
|
startAttemptBtn: document.getElementById('startAttemptBtn'),
|
|
backFrameBtn: document.getElementById('backFrameBtn'),
|
|
nextFrameBtn: document.getElementById('nextFrameBtn'),
|
|
advanceFiveBtn: document.getElementById('advanceFiveBtn'),
|
|
resetReplayBtn: document.getElementById('resetReplayBtn'),
|
|
submitBtn: document.getElementById('submitBtn'),
|
|
priceLow: document.getElementById('priceLow'),
|
|
priceHigh: document.getElementById('priceHigh'),
|
|
levelPrice: document.getElementById('levelPrice'),
|
|
choiceValue: document.getElementById('choiceValue'),
|
|
invalidationText: document.getElementById('invalidationText'),
|
|
};
|
|
|
|
const ctx = el.chart.getContext('2d');
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const response = await fetch(url, options);
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${url}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function postJson(url, payload = {}) {
|
|
return fetchJson(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
function setStatus(text) {
|
|
el.sessionMeta.textContent = text;
|
|
}
|
|
|
|
function clearNode(node) {
|
|
while (node.firstChild) node.removeChild(node.firstChild);
|
|
}
|
|
|
|
async function loadCases() {
|
|
const instrumentId = el.instrumentId.value || '1';
|
|
const limit = el.caseLimit.value || '20';
|
|
const type = el.caseType.value;
|
|
const params = new URLSearchParams({ instrument_id: instrumentId, limit });
|
|
if (type) params.set('case_type', type);
|
|
const payload = await fetchJson(`/training/cases?${params.toString()}`);
|
|
state.cases = payload.items || [];
|
|
renderCaseList();
|
|
await loadProgress();
|
|
setStatus(`Cases: ${state.cases.length}`);
|
|
}
|
|
|
|
async function loadProgress() {
|
|
const instrumentId = el.instrumentId.value || '1';
|
|
const [progress, recommendations, curriculum, history] = await Promise.all([
|
|
fetchJson('/training/progress?user_id=local'),
|
|
fetchJson(`/training/recommendations?user_id=local&instrument_id=${encodeURIComponent(instrumentId)}&limit=3`),
|
|
fetchJson(`/training/curriculum?user_id=local&instrument_id=${encodeURIComponent(instrumentId)}&limit_per_stage=2`),
|
|
fetchJson('/training/attempts/history?user_id=local&limit=4'),
|
|
]);
|
|
state.recommendations = recommendations.items || [];
|
|
state.attemptHistory = history.items || [];
|
|
state.curriculum = curriculum;
|
|
renderProgress(progress, recommendations, history);
|
|
renderCurriculum(curriculum);
|
|
}
|
|
|
|
async function generateCases() {
|
|
const payload = await postJson('/training/cases/generate', {
|
|
instrument_id: Number(el.instrumentId.value || 1),
|
|
timeframe: '1m',
|
|
case_types: el.caseType.value ? [el.caseType.value] : ['sweep', 'fvg', 'range', 'mss_displacement'],
|
|
limit_per_type: Math.min(Number(el.caseLimit.value || 10), 10),
|
|
});
|
|
setStatus(`Generated: ${payload.generated}`);
|
|
await loadCases();
|
|
}
|
|
|
|
function renderCaseList() {
|
|
clearNode(el.caseList);
|
|
state.cases.forEach((item) => {
|
|
const button = document.createElement('button');
|
|
button.className = `case-item ${state.selectedCase?.id === item.id ? 'selected' : ''}`;
|
|
button.addEventListener('click', () => selectCase(item.id));
|
|
|
|
const kind = document.createElement('div');
|
|
kind.className = 'case-kind';
|
|
kind.textContent = `${item.case_type} · ${item.difficulty || 'standard'}`;
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'case-time';
|
|
time.textContent = item.decision_ts || item.window_start_ts || `#${item.id}`;
|
|
|
|
button.append(kind, time);
|
|
el.caseList.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function renderProgress(payload, recommendations = { items: [] }, history = { items: [] }) {
|
|
clearNode(el.progressPanel);
|
|
const maturity = document.createElement('div');
|
|
maturity.innerHTML = `Maturity: <strong>${payload.precision_maturity}</strong>`;
|
|
el.progressPanel.appendChild(maturity);
|
|
|
|
const attempts = document.createElement('div');
|
|
attempts.textContent = `Attempts: ${payload.attempt_count}`;
|
|
el.progressPanel.appendChild(attempts);
|
|
|
|
const weak = document.createElement('div');
|
|
weak.textContent = `Weak: ${(payload.weakness_tags || []).join(', ') || 'none'}`;
|
|
el.progressPanel.appendChild(weak);
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'recommendation-title';
|
|
title.textContent = 'Next drills';
|
|
el.progressPanel.appendChild(title);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'recommendation-list';
|
|
(recommendations.items || []).forEach((item) => {
|
|
const button = document.createElement('button');
|
|
button.className = 'recommendation-item';
|
|
button.addEventListener('click', () => selectCase(item.id));
|
|
|
|
const kind = document.createElement('span');
|
|
kind.textContent = `#${item.id} ${item.case_type}`;
|
|
|
|
const reason = document.createElement('small');
|
|
const matched = (item.matched_weakness_tags || []).join(', ');
|
|
reason.textContent = matched || item.recommendation_reason || 'balanced';
|
|
|
|
button.append(kind, reason);
|
|
list.appendChild(button);
|
|
});
|
|
if (!(recommendations.items || []).length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'recommendation-empty';
|
|
empty.textContent = 'No active cases';
|
|
list.appendChild(empty);
|
|
}
|
|
el.progressPanel.appendChild(list);
|
|
|
|
const historyTitle = document.createElement('div');
|
|
historyTitle.className = 'recommendation-title';
|
|
historyTitle.textContent = 'Recent attempts';
|
|
el.progressPanel.appendChild(historyTitle);
|
|
|
|
const historyList = document.createElement('div');
|
|
historyList.className = 'attempt-history';
|
|
(history.items || []).forEach((item) => {
|
|
const button = document.createElement('button');
|
|
button.className = 'attempt-history-item';
|
|
button.addEventListener('click', () => loadAttempt(item.attempt_id));
|
|
|
|
const kind = document.createElement('span');
|
|
kind.textContent = `#${item.attempt_id} ${item.case_type}`;
|
|
|
|
const score = document.createElement('small');
|
|
score.textContent = item.score_pct == null ? item.status : `${item.score_pct}%`;
|
|
|
|
button.append(kind, score);
|
|
historyList.appendChild(button);
|
|
});
|
|
if (!(history.items || []).length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'recommendation-empty';
|
|
empty.textContent = 'No attempts yet';
|
|
historyList.appendChild(empty);
|
|
}
|
|
el.progressPanel.appendChild(historyList);
|
|
}
|
|
|
|
function renderCurriculum(payload) {
|
|
clearNode(el.curriculumPanel);
|
|
(payload?.stages || []).forEach((stage) => {
|
|
const card = document.createElement('div');
|
|
card.className = `stage-card ${stage.status}`;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'stage-header';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'stage-title';
|
|
title.textContent = stage.title;
|
|
|
|
const status = document.createElement('span');
|
|
status.className = 'stage-status';
|
|
status.textContent = stage.status;
|
|
|
|
header.append(title, status);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'stage-meta';
|
|
meta.textContent = `${stage.level} · ${stage.attempt_count}/${stage.required_attempts} attempts · ${stage.avg_score_pct}/${stage.required_avg_score_pct}`;
|
|
|
|
const bar = document.createElement('div');
|
|
bar.className = 'stage-bar';
|
|
const fill = document.createElement('span');
|
|
fill.style.width = `${Math.max(0, Math.min(stage.completion_pct || 0, 100))}%`;
|
|
bar.appendChild(fill);
|
|
|
|
const drills = document.createElement('div');
|
|
drills.className = 'stage-drills';
|
|
const startButton = document.createElement('button');
|
|
startButton.className = 'stage-drill primary';
|
|
startButton.disabled = stage.status === 'locked';
|
|
startButton.textContent = 'Start stage';
|
|
startButton.addEventListener('click', () => startCurriculumDrill(stage.stage_id));
|
|
drills.appendChild(startButton);
|
|
|
|
(stage.recommended_cases || []).forEach((item) => {
|
|
const button = document.createElement('button');
|
|
button.className = 'stage-drill';
|
|
button.disabled = stage.status === 'locked';
|
|
button.textContent = `#${item.id} ${item.case_type}`;
|
|
button.addEventListener('click', () => selectCase(item.id));
|
|
drills.appendChild(button);
|
|
});
|
|
|
|
card.append(header, meta, bar, drills);
|
|
el.curriculumPanel.appendChild(card);
|
|
});
|
|
}
|
|
|
|
async function startCurriculumDrill(stageId) {
|
|
const payload = await postJson('/training/curriculum/next', {
|
|
user_id: 'local',
|
|
instrument_id: Number(el.instrumentId.value || 1),
|
|
stage_id: stageId,
|
|
});
|
|
state.selectedCase = payload.case;
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
state.selectedCandleIndex = null;
|
|
renderCaseList();
|
|
renderCase();
|
|
setStatus(`Started ${payload.stage.title}`);
|
|
}
|
|
|
|
async function selectCase(caseId) {
|
|
const casePayload = await fetchJson(`/training/cases/${caseId}`);
|
|
const replay = await fetchJson(`/training/cases/${caseId}/replay`);
|
|
state.selectedCase = casePayload;
|
|
state.replay = replay;
|
|
state.attempt = null;
|
|
state.selectedCandleIndex = null;
|
|
renderCaseList();
|
|
renderCase();
|
|
}
|
|
|
|
async function loadAttempt(attemptId) {
|
|
const payload = await fetchJson(`/training/attempts/${attemptId}`);
|
|
state.selectedCase = payload.case;
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
state.selectedCandleIndex = null;
|
|
renderCaseList();
|
|
renderCase();
|
|
renderAttemptReview(payload);
|
|
setStatus(`Review attempt #${attemptId}`);
|
|
}
|
|
|
|
function renderCase() {
|
|
const current = state.selectedCase;
|
|
if (!current) {
|
|
el.caseTitle.textContent = 'No Case';
|
|
el.frameMeta.textContent = 'Frame: -';
|
|
clearNode(el.taskList);
|
|
drawCandles([]);
|
|
updateButtons();
|
|
return;
|
|
}
|
|
el.caseTitle.textContent = `Case #${current.id} · ${current.case_type}`;
|
|
renderTasks(current.tasks || []);
|
|
renderReplay();
|
|
updateButtons();
|
|
}
|
|
|
|
function renderTasks(tasks) {
|
|
clearNode(el.taskList);
|
|
tasks.forEach((task) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'task-row';
|
|
|
|
const type = document.createElement('div');
|
|
type.className = 'task-type';
|
|
type.textContent = task.task_type;
|
|
|
|
const prompt = document.createElement('div');
|
|
prompt.className = 'task-prompt';
|
|
prompt.textContent = task.prompt;
|
|
|
|
row.append(type, prompt);
|
|
el.taskList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderReplay() {
|
|
const replay = state.replay;
|
|
if (!replay) {
|
|
drawCandles([]);
|
|
el.frameMeta.textContent = 'Frame: -';
|
|
return;
|
|
}
|
|
el.frameMeta.textContent = `Frame: ${replay.frame_index + 1}/${replay.total_frames} · Decision: ${replay.decision_index + 1}`;
|
|
drawCandles(replay.visible_candles || []);
|
|
}
|
|
|
|
async function startAttempt() {
|
|
if (!state.selectedCase) return;
|
|
const payload = await postJson('/training/attempts', {
|
|
case_id: state.selectedCase.id,
|
|
user_id: 'local',
|
|
meta: { source: 'training_lab' },
|
|
});
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
state.selectedCandleIndex = null;
|
|
renderReplay();
|
|
updateButtons();
|
|
}
|
|
|
|
async function advanceAttempt(step) {
|
|
if (!state.attempt) return;
|
|
const payload = await postJson(`/training/attempts/${state.attempt.id}/advance`, { step });
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
renderReplay();
|
|
updateButtons();
|
|
}
|
|
|
|
async function resetAttempt() {
|
|
if (!state.attempt) return;
|
|
const payload = await postJson(`/training/attempts/${state.attempt.id}/reset`);
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
state.selectedCandleIndex = null;
|
|
renderReplay();
|
|
updateButtons();
|
|
}
|
|
|
|
async function submitAttempt() {
|
|
if (!state.attempt || !state.selectedCase) return;
|
|
await submitAnnotations();
|
|
const payload = await postJson(`/training/attempts/${state.attempt.id}/submit`, { reveal_answer: true });
|
|
state.attempt = payload.attempt;
|
|
state.replay = payload.replay;
|
|
if (payload.curriculum) {
|
|
state.curriculum = payload.curriculum;
|
|
renderCurriculum(payload.curriculum);
|
|
}
|
|
renderScores(payload);
|
|
await loadProgress();
|
|
renderReplay();
|
|
updateButtons();
|
|
}
|
|
|
|
async function submitAnnotations() {
|
|
const caseType = state.selectedCase.case_type;
|
|
if (caseType === 'range') {
|
|
await maybePostAnnotation('dealing_range', zonePayload());
|
|
await maybePostAnnotation('equilibrium', levelPayload());
|
|
await maybePostAnnotation('free_text', { text: el.invalidationText.value.trim() });
|
|
}
|
|
if (caseType === 'sweep') {
|
|
await maybePostAnnotation('liquidity_pool', zonePayload());
|
|
await maybePostAnnotation('multiple_choice', { value: el.choiceValue.value.trim() });
|
|
await maybePostAnnotation('invalidation', invalidationPayload());
|
|
}
|
|
if (caseType === 'fvg') {
|
|
await maybePostAnnotation('fvg', zonePayload());
|
|
await maybePostAnnotation('multiple_choice', { value: el.choiceValue.value.trim() });
|
|
await maybePostAnnotation('invalidation', invalidationPayload());
|
|
}
|
|
if (caseType === 'mss_displacement') {
|
|
await maybePostAnnotation('mss', { price: numberOrNull(el.levelPrice.value), direction: el.choiceValue.value.trim() });
|
|
await maybePostAnnotation('multiple_choice', { value: el.choiceValue.value.trim() });
|
|
await maybePostAnnotation('invalidation', invalidationPayload());
|
|
}
|
|
}
|
|
|
|
async function maybePostAnnotation(annotationType, payload) {
|
|
const meaningful = Object.values(payload).some((value) => value !== '' && value != null && !Number.isNaN(value));
|
|
if (!meaningful) return;
|
|
await postJson(`/training/attempts/${state.attempt.id}/annotations`, {
|
|
annotation_type: annotationType,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
function zonePayload() {
|
|
return {
|
|
price_low: numberOrNull(el.priceLow.value),
|
|
price_high: numberOrNull(el.priceHigh.value),
|
|
};
|
|
}
|
|
|
|
function levelPayload() {
|
|
return { price: numberOrNull(el.levelPrice.value) };
|
|
}
|
|
|
|
function invalidationPayload() {
|
|
return { condition: el.invalidationText.value.trim() };
|
|
}
|
|
|
|
function numberOrNull(value) {
|
|
if (value === '') return null;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
|
|
function renderScores(payload) {
|
|
clearNode(el.scorePanel);
|
|
const total = document.createElement('div');
|
|
total.className = 'score-total';
|
|
total.textContent = `Total: ${payload.total_score}/${payload.max_score}`;
|
|
el.scorePanel.appendChild(total);
|
|
|
|
if (payload.coach) {
|
|
const coach = document.createElement('div');
|
|
coach.className = 'coach-feedback';
|
|
const summary = document.createElement('div');
|
|
summary.className = 'coach-summary';
|
|
summary.textContent = payload.coach.summary;
|
|
coach.appendChild(summary);
|
|
(payload.coach.next_actions || []).forEach((action) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'coach-action';
|
|
item.textContent = action;
|
|
coach.appendChild(item);
|
|
});
|
|
el.scorePanel.appendChild(coach);
|
|
}
|
|
|
|
if (payload.stage_transition) {
|
|
const transition = document.createElement('div');
|
|
transition.className = `stage-transition ${payload.stage_transition.advanced ? 'advanced' : 'steady'}`;
|
|
const unlocked = (payload.stage_transition.unlocked_stage_ids || []).join(', ');
|
|
const mastered = (payload.stage_transition.mastered_stage_ids || []).join(', ');
|
|
transition.textContent = payload.stage_transition.advanced
|
|
? `Stage advanced: ${payload.stage_transition.from_active_stage_id} -> ${payload.stage_transition.to_active_stage_id}`
|
|
: `Stay on stage: ${payload.stage_transition.to_active_stage_id || payload.stage_transition.from_active_stage_id || 'current'}`;
|
|
if (unlocked || mastered) {
|
|
transition.textContent += ` · ${[mastered && `mastered ${mastered}`, unlocked && `unlocked ${unlocked}`].filter(Boolean).join(' · ')}`;
|
|
}
|
|
el.scorePanel.appendChild(transition);
|
|
}
|
|
|
|
if (payload.next_drill?.case) {
|
|
const next = document.createElement('div');
|
|
next.className = 'next-drill';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'next-drill-label';
|
|
label.textContent = `${payload.next_drill.stage_title || payload.next_drill.stage_id} · #${payload.next_drill.case.id} ${payload.next_drill.case.case_type}`;
|
|
|
|
const button = document.createElement('button');
|
|
button.className = 'stage-drill primary';
|
|
button.textContent = 'Start next drill';
|
|
button.addEventListener('click', () => startCurriculumDrill(payload.next_drill.stage_id));
|
|
|
|
next.append(label, button);
|
|
el.scorePanel.appendChild(next);
|
|
}
|
|
|
|
(payload.scores || []).forEach((score) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'score-row';
|
|
const category = document.createElement('span');
|
|
category.textContent = score.category;
|
|
const value = document.createElement('strong');
|
|
value.textContent = `${score.score}/${score.max_score}`;
|
|
row.append(category, value);
|
|
el.scorePanel.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderAttemptReview(payload) {
|
|
const scores = payload.scores || [];
|
|
const maxScore = scores.reduce((sum, item) => sum + Number(item.max_score || 0), 0);
|
|
const totalScore = payload.attempt?.total_score ?? scores.reduce((sum, item) => sum + Number(item.score || 0), 0);
|
|
renderScores({
|
|
total_score: totalScore,
|
|
max_score: maxScore,
|
|
scores,
|
|
});
|
|
}
|
|
|
|
function updateButtons() {
|
|
const hasCase = Boolean(state.selectedCase);
|
|
const hasAttempt = Boolean(state.attempt);
|
|
const isReplayMutable = hasAttempt && (!state.attempt.status || state.attempt.status === 'started');
|
|
const canMoveBack = isReplayMutable && state.replay && state.replay.frame_index > state.replay.decision_index;
|
|
el.startAttemptBtn.disabled = !hasCase || hasAttempt;
|
|
el.backFrameBtn.disabled = !canMoveBack;
|
|
el.nextFrameBtn.disabled = !isReplayMutable || state.replay?.is_complete;
|
|
el.advanceFiveBtn.disabled = !isReplayMutable || state.replay?.is_complete;
|
|
el.resetReplayBtn.disabled = !canMoveBack;
|
|
el.submitBtn.disabled = !hasAttempt || state.attempt?.status === 'graded';
|
|
}
|
|
|
|
function drawCandles(items) {
|
|
ctx.clearRect(0, 0, el.chart.width, el.chart.height);
|
|
state.chartModel = null;
|
|
if (!items.length) {
|
|
ctx.fillStyle = '#9aa8ad';
|
|
ctx.fillText('No candles', 24, 36);
|
|
return;
|
|
}
|
|
|
|
const highs = items.map((candle) => Number(candle.high));
|
|
const lows = items.map((candle) => Number(candle.low));
|
|
const min = Math.min(...lows);
|
|
const max = Math.max(...highs);
|
|
const padX = 30;
|
|
const padY = 24;
|
|
const width = (el.chart.width - padX * 2) / items.length;
|
|
const scaleY = (value) => {
|
|
const ratio = (Number(value) - min) / Math.max(max - min, 1e-9);
|
|
return el.chart.height - padY - ratio * (el.chart.height - padY * 2);
|
|
};
|
|
state.chartModel = { items, min, max, padX, padY, width, scaleY };
|
|
|
|
drawGrid(min, max, scaleY);
|
|
|
|
items.forEach((candle, index) => {
|
|
const x = padX + index * width + width / 2;
|
|
const open = Number(candle.open);
|
|
const close = Number(candle.close);
|
|
const high = Number(candle.high);
|
|
const low = Number(candle.low);
|
|
const color = close >= open ? '#32d583' : '#ff6b6b';
|
|
ctx.strokeStyle = color;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, scaleY(high));
|
|
ctx.lineTo(x, scaleY(low));
|
|
ctx.stroke();
|
|
const bodyTop = scaleY(Math.max(open, close));
|
|
const bodyBottom = scaleY(Math.min(open, close));
|
|
ctx.fillRect(x - width * 0.3, bodyTop, Math.max(width * 0.6, 2), Math.max(bodyBottom - bodyTop, 2));
|
|
});
|
|
|
|
drawFrameMarkers(items.length);
|
|
drawInputOverlay();
|
|
drawAnswerOverlay();
|
|
drawSelectedCandle();
|
|
}
|
|
|
|
function drawGrid(min, max, scaleY) {
|
|
ctx.save();
|
|
ctx.strokeStyle = '#182126';
|
|
ctx.fillStyle = '#6f7d82';
|
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
for (let index = 0; index <= 4; index += 1) {
|
|
const price = min + ((max - min) * index) / 4;
|
|
const y = scaleY(price);
|
|
ctx.beginPath();
|
|
ctx.moveTo(30, y);
|
|
ctx.lineTo(el.chart.width - 30, y);
|
|
ctx.stroke();
|
|
ctx.fillText(formatPrice(price), 6, y - 3);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFrameMarkers(visibleCount) {
|
|
const replay = state.replay;
|
|
const model = state.chartModel;
|
|
if (!replay || !model) return;
|
|
if (replay.decision_index >= 0 && replay.decision_index < visibleCount) {
|
|
drawVerticalMarker(replay.decision_index, '#f2c14e');
|
|
}
|
|
if (replay.frame_index >= 0 && replay.frame_index < visibleCount) {
|
|
drawVerticalMarker(replay.frame_index, '#7dd3c7');
|
|
}
|
|
}
|
|
|
|
function drawVerticalMarker(index, color) {
|
|
const model = state.chartModel;
|
|
const x = model.padX + index * model.width + model.width / 2;
|
|
ctx.save();
|
|
ctx.strokeStyle = color;
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, model.padY);
|
|
ctx.lineTo(x, el.chart.height - model.padY);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawInputOverlay() {
|
|
const low = numberOrNull(el.priceLow.value);
|
|
const high = numberOrNull(el.priceHigh.value);
|
|
const level = numberOrNull(el.levelPrice.value);
|
|
if (low != null && high != null) {
|
|
drawPriceZone(Math.min(low, high), Math.max(low, high), '#7dd3c7', 0.16);
|
|
}
|
|
if (level != null) {
|
|
drawPriceLine(level, '#7dd3c7');
|
|
}
|
|
}
|
|
|
|
function drawAnswerOverlay() {
|
|
const replay = state.replay;
|
|
const answer = replay?.case?.answer_key;
|
|
if (!replay?.answer_visible || !answer) return;
|
|
if (answer.lower != null && answer.upper != null) {
|
|
drawPriceZone(answer.lower, answer.upper, '#f2c14e', 0.2);
|
|
}
|
|
if (answer.low != null && answer.high != null) {
|
|
drawPriceZone(answer.low, answer.high, '#f2c14e', 0.16);
|
|
}
|
|
if (answer.equilibrium != null) {
|
|
drawPriceLine(answer.equilibrium, '#f2c14e');
|
|
}
|
|
if (answer.pool_price_low != null && answer.pool_price_high != null) {
|
|
drawPriceZone(answer.pool_price_low, answer.pool_price_high, '#f2c14e', 0.2);
|
|
}
|
|
if (answer.broken_level != null) {
|
|
drawPriceLine(answer.broken_level, '#ff7a59');
|
|
drawPriceLabel(answer.broken_level, `${answer.direction || 'MSS'} break`, '#ff7a59');
|
|
}
|
|
}
|
|
|
|
function drawPriceZone(low, high, color, alpha) {
|
|
const model = state.chartModel;
|
|
if (!model) return;
|
|
const top = model.scaleY(high);
|
|
const bottom = model.scaleY(low);
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillRect(model.padX, top, el.chart.width - model.padX * 2, Math.max(bottom - top, 2));
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPriceLine(price, color) {
|
|
const model = state.chartModel;
|
|
if (!model) return;
|
|
const y = model.scaleY(price);
|
|
ctx.save();
|
|
ctx.strokeStyle = color;
|
|
ctx.setLineDash([6, 5]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(model.padX, y);
|
|
ctx.lineTo(el.chart.width - model.padX, y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPriceLabel(price, text, color) {
|
|
const model = state.chartModel;
|
|
if (!model) return;
|
|
const y = model.scaleY(price);
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
const label = String(text || '').slice(0, 28);
|
|
const width = ctx.measureText(label).width + 12;
|
|
const x = Math.max(model.padX, el.chart.width - model.padX - width);
|
|
ctx.globalAlpha = 0.92;
|
|
ctx.fillRect(x, Math.max(model.padY, y - 18), width, 18);
|
|
ctx.fillStyle = '#111315';
|
|
ctx.fillText(label, x + 6, Math.max(model.padY + 12, y - 5));
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawSelectedCandle() {
|
|
const model = state.chartModel;
|
|
if (!model || state.selectedCandleIndex == null) return;
|
|
if (state.selectedCandleIndex < 0 || state.selectedCandleIndex >= model.items.length) return;
|
|
const x = model.padX + state.selectedCandleIndex * model.width;
|
|
ctx.save();
|
|
ctx.strokeStyle = '#edf2f4';
|
|
ctx.globalAlpha = 0.35;
|
|
ctx.strokeRect(x, model.padY, model.width, el.chart.height - model.padY * 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
function handleChartClick(event) {
|
|
const model = state.chartModel;
|
|
if (!model || !model.items.length) return;
|
|
const rect = el.chart.getBoundingClientRect();
|
|
const canvasX = ((event.clientX - rect.left) / rect.width) * el.chart.width;
|
|
const index = Math.max(0, Math.min(model.items.length - 1, Math.floor((canvasX - model.padX) / model.width)));
|
|
const candle = model.items[index];
|
|
state.selectedCandleIndex = index;
|
|
el.priceLow.value = formatPrice(candle.low);
|
|
el.priceHigh.value = formatPrice(candle.high);
|
|
el.levelPrice.value = formatPrice(candle.close);
|
|
setStatus(`Selected #${index + 1} · O:${formatPrice(candle.open)} H:${formatPrice(candle.high)} L:${formatPrice(candle.low)} C:${formatPrice(candle.close)}`);
|
|
renderReplay();
|
|
}
|
|
|
|
function formatPrice(value) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return '';
|
|
if (Math.abs(number) >= 1000) return number.toFixed(1);
|
|
if (Math.abs(number) >= 10) return number.toFixed(2);
|
|
return number.toFixed(4);
|
|
}
|
|
|
|
el.loadCasesBtn.addEventListener('click', () => loadCases().catch((error) => setStatus(error.message)));
|
|
el.generateCasesBtn.addEventListener('click', () => generateCases().catch((error) => setStatus(error.message)));
|
|
el.startAttemptBtn.addEventListener('click', () => startAttempt().catch((error) => setStatus(error.message)));
|
|
el.backFrameBtn.addEventListener('click', () => advanceAttempt(-1).catch((error) => setStatus(error.message)));
|
|
el.nextFrameBtn.addEventListener('click', () => advanceAttempt(1).catch((error) => setStatus(error.message)));
|
|
el.advanceFiveBtn.addEventListener('click', () => advanceAttempt(5).catch((error) => setStatus(error.message)));
|
|
el.resetReplayBtn.addEventListener('click', () => resetAttempt().catch((error) => setStatus(error.message)));
|
|
el.submitBtn.addEventListener('click', () => submitAttempt().catch((error) => setStatus(error.message)));
|
|
el.chart.addEventListener('click', handleChartClick);
|
|
[el.priceLow, el.priceHigh, el.levelPrice].forEach((input) => {
|
|
input.addEventListener('input', () => renderReplay());
|
|
});
|
|
|
|
loadCases().catch((error) => setStatus(error.message));
|