Files

488 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Двигун гри: стан, рендеринг графіків, механіка вікторини, навігація вкладками.
// Контент — у quizzes.js; сирі дані — у game_data.js.
const S = GAME_DATA.scenario;
const fmt = n => n.toLocaleString('uk-UA');
const pct = n => (n > 0 ? '+' : '') + n.toFixed(1).replace('.', ',') + '%';
// Helpers місяців
const ALL_MONTHS = GAME_DATA.monthly_totals.map(d => d.month);
const MO_LABELS = ALL_MONTHS.map(m => {
const [y, mo] = m.split('-');
const names = { '01':'Січ','02':'Лют','03':'Бер','04':'Кві','05':'Тра','06':'Чер','07':'Лип','08':'Сер','09':'Вер','10':'Жов','11':'Лис','12':'Гру' };
return names[mo] + ' ' + y.slice(2);
});
const seg = (m, s) => GAME_DATA.monthly_by_segment.find(d => d.month === m && d.segment === s) || {};
const tot = m => GAME_DATA.monthly_totals.find(d => d.month === m) || {};
const segSeries = (s, f) => ALL_MONTHS.map(m => seg(m, s)[f]);
const totSeries = f => ALL_MONTHS.map(m => tot(m)[f]);
const kFmt = v => v >= 1000 ? (v / 1000).toFixed(0) + 'К' : v;
// Helpers графіків
const COLORS = { primary: '#003964', blue: '#00BBCE', green: '#A7C539', red: '#F15B43', orange: '#ff9800' };
const baseOpts = (legend, scaleOverrides) => ({
plugins: { legend: legend ? { position: 'top', labels: { boxWidth: 14, font: { size: 11 } } } : { display: false }, datalabels: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: kFmt } }, ...scaleOverrides },
maintainAspectRatio: true,
});
const lineDs = (label, data, color, extra) => ({ label, data, borderColor: color, tension: 0.25, pointRadius: 3, borderWidth: 2, ...extra });
const mkChart = (canvas, type, datasets, labels, opts) => new Chart(document.getElementById(canvas), { type, data: { labels, datasets }, options: opts });
function destroyChart(key) { if (charts[key]) { charts[key].destroy(); delete charts[key]; } }
// Стан
const state = {
tabsVisited: new Set(), subViewsVisited: new Set(),
overviewMode: 'monthly', ridersMode: 'monthly',
currentSubTab: 'dayofweek',
insights: { yoyPositive: false, longRidesCollapse: false, casualDropMore: false, mixShift: false, durationParadox: false, conclusionSeasonal: false, conclusionParadox: false },
quizzes: {}, wrongCount: 0,
checked: { conclusionSeasonal: false, conclusionParadox: false },
deadEndLessons: new Set(), submitted: false,
};
const charts = {};
// --- Рендеринг вікторини ---
function renderQuiz(quizId, container) {
const q = QUIZZES[quizId], solved = state.quizzes[quizId] === 'correct';
const correctIdx = q.options.findIndex(o => o.correct);
const el = document.createElement('div');
el.className = 'quiz-section'; el.id = 'quiz-' + quizId;
el.innerHTML = `<div class="quiz-prompt">${q.prompt}</div><div class="quiz-options">
${q.options.map((o, i) => solved
? `<button class="quiz-opt ${i === correctIdx ? 'correct' : 'faded'}" style="pointer-events:none;"><span class="q-icon">${i === correctIdx ? '✓' : ''}</span><span>${o.text}</span></button>`
: `<button class="quiz-opt" id="qopt-${quizId}-${i}" onclick="handleQuizChoice('${quizId}',${i})"><span class="q-icon"></span><span>${o.text}</span></button>`
).join('')}</div>
<div class="quiz-feedback ${solved ? 'visible correct-fb' : ''}" id="qfb-${quizId}">${solved ? q.options[correctIdx].feedback : ''}</div>`;
container.appendChild(el);
}
function handleQuizChoice(quizId, optIdx) {
const q = QUIZZES[quizId], opt = q.options[optIdx];
const fb = document.getElementById('qfb-' + quizId);
const btn = document.getElementById('qopt-' + quizId + '-' + optIdx);
if (opt.correct) {
state.quizzes[quizId] = 'correct';
btn.classList.add('correct'); btn.querySelector('.q-icon').textContent = '✓';
fb.className = 'quiz-feedback visible correct-fb'; fb.textContent = opt.feedback;
q.options.forEach((_, i) => { if (i !== optIdx) { const o = document.getElementById('qopt-'+quizId+'-'+i); if (o) o.classList.add('faded'); } });
if (q.insightKey) unlockInsight(q.insightKey);
if (q.lessonKey) state.deadEndLessons.add(q.lessonKey);
const lessonEl = document.getElementById('lesson-' + quizId);
if (lessonEl) lessonEl.classList.remove('lesson-hidden');
} else {
state.wrongCount++;
btn.classList.add('wrong'); btn.querySelector('.q-icon').textContent = '✗';
fb.className = 'quiz-feedback visible wrong-fb'; fb.textContent = opt.feedback;
setTimeout(() => { btn.classList.remove('wrong'); btn.classList.add('disabled'); btn.querySelector('.q-icon').textContent = ''; }, 2000);
}
updateViewCount();
}
function quizInto(spotId, quizId) { const s = document.getElementById(spotId); if (!s) return; s.innerHTML = ''; renderQuiz(quizId, s); }
// --- Картки інсайтів ---
function renderInsightCards() {
document.getElementById('observationCards').innerHTML = OBSERVATIONS.map(insightCardHTML).join('');
document.getElementById('conclusionCards').innerHTML = CONCLUSIONS.map(insightCardHTML).join('');
document.getElementById('connectionCard').innerHTML = '';
}
function insightCardHTML(o) {
const isCon = o.tag === 'conclusion';
const unlocked = state.insights[o.key];
if (!unlocked) {
return `<div class="insight-card locked ${isCon ? 'conclusion' : ''}" id="card-${o.key}">
<div class="insight-placeholder">🔒 Розблокується після відповідей на питання дашборду</div>
${isCon ? `<div class="insight-check" id="check-${o.key}"></div>` : ''}</div>`;
}
const tagLabel = o.tag === 'conclusion' ? 'висновок' : 'спостереження';
return `<div class="insight-card unlocked ${isCon ? 'conclusion' : ''}" id="card-${o.key}">
<div class="insight-text">${o.text}</div><span class="insight-tag ${o.tag}">${tagLabel}</span>
${isCon ? `<div class="insight-check ${state.checked[o.key] ? 'checked' : ''}" id="check-${o.key}" onclick="toggleCheck('${o.key}')">${state.checked[o.key] ? '✓' : ''}</div>` : ''}</div>`;
}
function unlockInsight(key) {
if (state.insights[key]) return;
state.insights[key] = true;
renderInsightCards();
const card = document.getElementById('card-' + key);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (state.insights.casualDropMore && state.insights.yoyPositive) unlockInsight('conclusionSeasonal');
if (state.insights.mixShift && state.insights.durationParadox) unlockInsight('conclusionParadox');
if (state.insights.conclusionSeasonal && state.insights.conclusionParadox) {
const el = document.getElementById('connectionCard');
if (el && !el.innerHTML) el.innerHTML = `<div class="connection-card"><strong>Один корінь — два симптоми.</strong> Передові замовники перестають замовляти рейси, коли холоднішає. Це викликає і падіння кількості рейсів, і парадокс тривалості — сезонний зсув композиції тягне обидві метрики.</div>`;
}
updateSubmit();
}
function toggleCheck(key) {
if (!state.insights[key]) return;
state.checked[key] = !state.checked[key];
const el = document.getElementById('check-' + key);
el.classList.toggle('checked', state.checked[key]); el.textContent = state.checked[key] ? '✓' : '';
updateSubmit();
}
function updateSubmit() {
document.getElementById('submitBtn').disabled = !(state.checked.conclusionSeasonal || state.checked.conclusionParadox);
if (state.insights.conclusionSeasonal || state.insights.conclusionParadox) {
const h = document.getElementById('submitHint'); h.textContent = 'Познач висновки, які хочеш надіслати, і тисни «Надіслати»'; h.style.color = '';
}
}
// --- Облік переглядів ---
function recordView(viewId) {
(viewId.startsWith('sub:') ? state.subViewsVisited : state.tabsVisited).add(viewId);
updateViewCount();
}
function updateViewCount() {
const a = Object.values(state.quizzes).filter(v => v === 'correct').length, w = state.wrongCount;
const total = Object.keys(QUIZZES).length;
document.getElementById('viewsTag').textContent = (a > 0 ? `${a}/${total} відповідей` : '') + (w > 0 ? ` · ${w} помилок` : '');
}
// --- Перемикання вкладок ---
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === 'pane-' + tabId));
recordView(tabId);
({ overview: renderOverview, breakdowns: renderBreakdowns, riders: renderRiders, duration: renderDuration })[tabId]();
}
// --- Вкладка «Огляд» ---
function toggleOverview(mode) {
state.overviewMode = mode;
document.querySelectorAll('#pane-overview .toggle-btn').forEach(b => b.classList.toggle('active', b.textContent === (mode === 'monthly' ? 'Місячна' : 'Денна')));
renderOverview();
}
function renderOverview() {
destroyChart('overviewRides'); destroyChart('overviewDuration');
const spot = document.getElementById('quiz-overview-spot'); spot.innerHTML = '';
if (state.overviewMode === 'monthly') {
charts.overviewRides = mkChart('chartOverviewRides', 'line',
[lineDs('Усього рейсів', totSeries('rides'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.08)', fill: true })],
MO_LABELS, baseOpts(false));
charts.overviewDuration = mkChart('chartOverviewDuration', 'line',
[lineDs('Сер. час обороту', totSeries('avg_duration'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.08)', fill: true })],
MO_LABELS, baseOpts(false, { y: { beginAtZero: false } }));
} else {
const daily = GAME_DATA.daily_totals;
const labels = daily.map((d, i) => {
const dt = new Date(d.date);
const monthNames = ['Січ','Лют','Бер','Кві','Тра','Чер','Лип','Сер','Вер','Жов','Лис','Гру'];
return (dt.getDate() === 1) ? monthNames[dt.getMonth()] + ' ' + String(dt.getFullYear()).slice(2) : '';
});
charts.overviewRides = mkChart('chartOverviewRides', 'line',
[{ label: 'Рейсів за добу', data: daily.map(d => d.rides), borderColor: COLORS.primary, backgroundColor: 'rgba(0,57,100,0.06)', fill: true, tension: 0.1, pointRadius: 0, borderWidth: 1.5 }],
labels, { ...baseOpts(false), scales: { y: { beginAtZero: true, ticks: { callback: kFmt } }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
charts.overviewDuration = mkChart('chartOverviewDuration', 'line',
[{ label: 'Сер. час обороту', data: daily.map(d => d.avg_duration), borderColor: COLORS.primary, backgroundColor: 'rgba(0,57,100,0.06)', fill: true, tension: 0.1, pointRadius: 0, borderWidth: 1.5 }],
labels, { ...baseOpts(false), scales: { y: { beginAtZero: false }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
}
renderQuiz('overview', spot);
}
// --- Вкладка «Розрізи» ---
function switchSubTab(subId) {
state.currentSubTab = subId;
document.querySelectorAll('.sub-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.subtab === subId));
recordView('sub:' + subId); renderBreakdowns();
}
function renderBreakdowns() {
const c = document.getElementById('breakdowns-content'); c.innerHTML = '';
destroyChart('bd1'); destroyChart('bd2');
({ dayofweek: renderDayOfWeek, hourly: renderHourly, vehicletypes: renderVehicleTypes, duration_buckets: renderDurationBuckets, stations: renderStations })[state.currentSubTab](c);
}
function renderDayOfWeek(container) {
const days = ['Понеділок','Вівторок','Середа','Четвер',"П'ятниця",'Субота','Неділя'];
const shortDays = ['Пн','Вт','Ср','Чт','Пт','Сб','Нд'];
const octData = days.map(d => { const r = GAME_DATA.day_of_week.find(x => x.month === '2023-10' && x.weekday === d); return r ? r.rides : 0; });
const novData = days.map(d => { const r = GAME_DATA.day_of_week.find(x => x.month === '2023-11' && x.weekday === d); return r ? r.rides : 0; });
const changes = days.map((_, i) => octData[i] > 0 ? ((novData[i] - octData[i]) / octData[i] * 100) : 0);
container.innerHTML = `
<div class="chart-card"><h3>Рейси за днем тижня: жовтень vs листопад (сирі суми)</h3><canvas id="chartBd1"></canvas></div>
<div class="chart-card"><h3>% зміни за днем (жовтень &rarr; листопад)</h3><canvas id="chartBd2"></canvas></div>
<div id="quiz-dayofweek-spot"></div>
<div class="lesson-card lesson-hidden" id="lesson-dayofweek"><h4>Аналітичний урок</h4><p>Коли порівнюєш місяці, перевір, чи мають вони однакову кількість днів кожного дня тижня. У жовтні 2023 було 5 понеділків і вівторків, але лише 4 четверги; у листопаді 2023 — навпаки. Сирі суми створюють хибні патерни.</p></div>`;
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'bar', data: { labels: shortDays,
datasets: [
{ label: 'Жовтень', data: octData, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novData, backgroundColor: COLORS.blue },
]}, options: { plugins: { legend: { position: 'top', labels: { boxWidth: 14 } }, datalabels: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: kFmt } } } }
});
charts.bd2 = new Chart(document.getElementById('chartBd2'), {
type: 'bar', data: { labels: shortDays,
datasets: [{ data: changes, backgroundColor: changes.map(c => c > -10 ? COLORS.green : c > -30 ? COLORS.orange : COLORS.red) }] },
options: { plugins: { legend: { display: false }, datalabels: { display: true, color: '#333', font: { weight: 'bold', size: 11 }, formatter: v => v.toFixed(1).replace('.', ',') + '%', anchor: 'end', align: 'top' } }, scales: { y: { ticks: { callback: v => v + '%' } } } },
plugins: [ChartDataLabels]
});
quizInto('quiz-dayofweek-spot', 'dayofweek');
}
function renderHourly(container) {
const hours = Array.from({ length: 24 }, (_, i) => i);
const hlabels = hours.map(h => h % 3 === 0 ? h + ':00' : '');
const getTotal = mo => hours.map(h => { const d = GAME_DATA.hourly_totals.find(x => x.month === mo && x.hour === h); return d ? d.rides : 0; });
container.innerHTML = `
<div class="chart-card"><h3>Рейси за годинами доби: жовтень vs листопад</h3><canvas id="chartBd1"></canvas></div>
<div id="quiz-hourly-spot"></div>
<div class="lesson-card lesson-hidden" id="lesson-hourly"><h4>Аналітичний урок</h4><p>Коли дві криві за різні періоди мають однакову форму, вимір не пояснює зміну. Це зсув обсягу, а не зсув патерну. Шукай виміри, де змінюється сама <em>форма</em>.</p></div>`;
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'line', data: { labels: hlabels, datasets: [
{ label: 'Жовтень', data: getTotal('2023-10'), borderColor: COLORS.primary, fill: true, backgroundColor: COLORS.primary + '1a', tension: 0.3, pointRadius: 0 },
{ label: 'Листопад', data: getTotal('2023-11'), borderColor: COLORS.blue, fill: false, tension: 0.3, pointRadius: 0 },
]}, options: { plugins: { legend: { position: 'top', labels: { boxWidth: 14 } }, datalabels: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: kFmt } }, x: { ticks: { autoSkip: true, maxTicksLimit: 8 } } }, aspectRatio: 2.2 }
});
quizInto('quiz-hourly-spot', 'hourly');
}
function renderVehicleTypes(container) {
const types = [...new Set(GAME_DATA.bike_type.map(d => d.bike_type))].sort();
const octData = types.map(t => { const r = GAME_DATA.bike_type.find(x => x.month === '2023-10' && x.bike_type === t); return r ? r.rides : 0; });
const novData = types.map(t => { const r = GAME_DATA.bike_type.find(x => x.month === '2023-11' && x.bike_type === t); return r ? r.rides : 0; });
const changes = types.map((_, i) => octData[i] > 0 ? ((novData[i] - octData[i]) / octData[i] * 100) : 0);
container.innerHTML = `
<div class="chart-card"><h3>Рейси за класом техніки: жовтень vs листопад</h3><canvas id="chartBd1"></canvas></div>
<div id="quiz-vehicletypes-spot"></div>
<div class="lesson-card lesson-hidden" id="lesson-vehicletypes"><h4>Аналітичний урок</h4><p>Коли всі значення у вимірі рухаються в одному напрямку приблизно з однаковою силою, цей вимір не пояснює варіацію. Шукай виміри, де значення <em>розходяться</em>.</p></div>`;
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'bar', data: { labels: types,
datasets: [
{ label: 'Жовтень', data: octData, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novData, backgroundColor: COLORS.blue },
]}, options: { plugins: { legend: { position: 'top', labels: { boxWidth: 14 } }, datalabels: { display: true, color: '#333', font: { size: 10 }, formatter: (v, ctx) => { const i = ctx.dataIndex; const ci = ctx.datasetIndex; if (ci === 1) return changes[i].toFixed(1).replace('.', ',') + '%'; return ''; }, anchor: 'end', align: 'top' } }, scales: { y: { beginAtZero: true, ticks: { callback: kFmt } } } },
plugins: [ChartDataLabels]
});
quizInto('quiz-vehicletypes-spot', 'vehicletypes');
}
function renderDurationBuckets(container) {
const buckets = ['0-5 хв', '5-15 хв', '15-30 хв', '30-60 хв', '60+ хв'];
const octData = buckets.map(b => { const r = GAME_DATA.duration_buckets.find(x => x.month === '2023-10' && x.bucket === b); return r ? r.rides : 0; });
const novData = buckets.map(b => { const r = GAME_DATA.duration_buckets.find(x => x.month === '2023-11' && x.bucket === b); return r ? r.rides : 0; });
const changes = buckets.map((_, i) => octData[i] > 0 ? ((novData[i] - octData[i]) / octData[i] * 100) : 0);
container.innerHTML = `
<div class="chart-card"><h3>% зміни за кошиком тривалості (жовтень &rarr; листопад)</h3><canvas id="chartBd1"></canvas></div>
<div class="chart-card"><h3>Рейси за кошиком тривалості: жовтень vs листопад</h3><canvas id="chartBd2"></canvas></div>
<div id="quiz-duration_buckets-spot"></div>`;
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'bar', data: { labels: buckets,
datasets: [{ data: changes, backgroundColor: changes.map(c => c > -30 ? COLORS.green : c > -45 ? COLORS.orange : COLORS.red) }] },
options: { plugins: { legend: { display: false }, datalabels: { display: true, color: '#333', font: { weight: 'bold', size: 12 }, formatter: v => v.toFixed(1).replace('.', ',') + '%', anchor: 'end', align: 'top' } }, scales: { y: { ticks: { callback: v => v + '%' } } } },
plugins: [ChartDataLabels]
});
charts.bd2 = new Chart(document.getElementById('chartBd2'), {
type: 'bar', data: { labels: buckets,
datasets: [
{ label: 'Жовтень', data: octData, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novData, backgroundColor: COLORS.blue },
]}, options: { plugins: { legend: { position: 'top', labels: { boxWidth: 14 } }, datalabels: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: kFmt } } } }
});
quizInto('quiz-duration_buckets-spot', 'duration_buckets');
}
function renderStations(container) {
const data = GAME_DATA.station_comparison.slice(0, 10);
container.innerHTML = `
<div class="chart-card"><h3>Топ-10 вузлів — % зміни (жовтень &rarr; листопад)</h3><canvas id="chartBd1"></canvas></div>
<div id="quiz-stations-spot"></div>
<div class="lesson-card lesson-hidden" id="lesson-stations"><h4>Аналітичний урок</h4><p>Коли патерн рівномірний по всіх значеннях, причина системна. Перебирати 800+ вузлів по одному — змарновані зусилля.</p></div>`;
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'bar', data: { labels: data.map(d => d.station),
datasets: [{ data: data.map(d => d.change_pct), backgroundColor: data.map(d => d.change_pct < -40 ? COLORS.red : d.change_pct < -25 ? COLORS.orange : COLORS.green) }] },
options: { indexAxis: 'y', plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1).replace('.', ',') + '%' } }, datalabels: { display: true, anchor: 'end', align: 'right', color: '#333', font: { weight: 'bold', size: 11 }, formatter: v => v.toFixed(1).replace('.', ',') + '%' } }, scales: { x: { ticks: { callback: v => v + '%' } } } },
plugins: [ChartDataLabels]
});
quizInto('quiz-stations-spot', 'stations');
}
// --- Вкладка «Замовники» ---
function toggleRiders(mode) {
state.ridersMode = mode;
document.querySelectorAll('#pane-riders .toggle-btn').forEach(b => b.classList.toggle('active', b.textContent === (mode === 'monthly' ? 'Місячна' : 'Денна')));
renderRiders();
}
function renderRiders() {
destroyChart('riderRides'); destroyChart('riderMix');
const spot = document.getElementById('quiz-riders-spot'); spot.innerHTML = '';
if (state.ridersMode === 'monthly') {
charts.riderRides = mkChart('chartRiderRides', 'line', [
lineDs('Тилові', segSeries('тиловий', 'rides'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.06)', fill: true }),
lineDs('Передові', segSeries('передовий', 'rides'), COLORS.blue, { backgroundColor: 'rgba(0,187,206,0.06)', fill: true }),
], MO_LABELS, baseOpts(true));
const shares = ALL_MONTHS.map(m => {
const c = seg(m, 'передовий').rides || 0, t = tot(m).rides || 1;
return Math.round(c / t * 100);
});
charts.riderMix = new Chart(document.getElementById('chartRiderMix'), {
type: 'line', data: { labels: MO_LABELS, datasets: [{ label: 'Частка передових', data: shares, borderColor: COLORS.blue, backgroundColor: 'rgba(0,187,206,0.1)', fill: true, tension: 0.25, pointRadius: 5 }] },
options: { plugins: { legend: { display: false }, datalabels: { display: true, color: COLORS.primary, font: { weight: 'bold', size: 11 }, formatter: v => v + '%', anchor: 'end', align: 'top' } }, scales: { y: { min: 15, max: 50, ticks: { callback: v => v + '%' } } } },
plugins: [ChartDataLabels]
});
} else {
const daily = GAME_DATA.daily_by_segment;
const dates = [...new Set(daily.map(d => d.date))];
const monthNames = ['Січ','Лют','Бер','Кві','Тра','Чер','Лип','Сер','Вер','Жов','Лис','Гру'];
const labels = dates.map(d => {
const dt = new Date(d);
return dt.getDate() === 1 ? monthNames[dt.getMonth()] + ' ' + String(dt.getFullYear()).slice(2) : '';
});
const memberRides = dates.map(d => { const r = daily.find(x => x.date === d && x.segment === 'тиловий'); return r ? r.rides : 0; });
const casualRides = dates.map(d => { const r = daily.find(x => x.date === d && x.segment === 'передовий'); return r ? r.rides : 0; });
charts.riderRides = mkChart('chartRiderRides', 'line', [
{ label: 'Тилові', data: memberRides, borderColor: COLORS.primary, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,57,100,0.06)' },
{ label: 'Передові', data: casualRides, borderColor: COLORS.blue, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,187,206,0.06)' },
], labels, { ...baseOpts(true), scales: { y: { beginAtZero: true, ticks: { callback: kFmt } }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
const shareData = dates.map(d => {
const c = daily.find(x => x.date === d && x.segment === 'передовий');
const m = daily.find(x => x.date === d && x.segment === 'тиловий');
const cv = c ? c.rides : 0, mv = m ? m.rides : 0;
return mv + cv > 0 ? Math.round(cv / (cv + mv) * 100) : 0;
});
charts.riderMix = mkChart('chartRiderMix', 'line',
[{ label: 'Частка передових', data: shareData, borderColor: COLORS.blue, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,187,206,0.1)' }],
labels, { ...baseOpts(false), scales: { y: { min: 10, max: 55, ticks: { callback: v => v + '%' } }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
}
renderQuiz('requesters_reveal', spot);
renderQuiz('requesters_mix', spot);
}
// --- Вкладка «Час обороту» ---
function renderDuration() {
destroyChart('durChange'); destroyChart('durTime');
const oc = seg('2023-10', 'передовий').avg_duration, nc = seg('2023-11', 'передовий').avg_duration;
const om = seg('2023-10', 'тиловий').avg_duration, nm = seg('2023-11', 'тиловий').avg_duration;
const cc = (nc - oc) / oc * 100, cm = (nm - om) / om * 100, ca = S.duration_change_pct;
const groups = ['Передові', 'Тилові', 'Загалом'];
const octVals = [oc, om, S.prior_avg_duration];
const novVals = [nc, nm, S.focus_avg_duration];
const changePcts = [cc, cm, ca];
charts.durChange = new Chart(document.getElementById('chartDurChange'), {
type: 'bar', data: { labels: groups,
datasets: [
{ label: 'Жовтень', data: octVals, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novVals, backgroundColor: COLORS.blue },
]},
options: {
plugins: {
legend: { position: 'top', labels: { boxWidth: 14 } },
datalabels: { display: true, color: '#333', font: { size: 11, weight: 'bold' },
formatter: (v, ctx) => {
const i = ctx.dataIndex;
if (ctx.datasetIndex === 1) return v + ' хв (' + changePcts[i].toFixed(1).replace('.', ',') + '%)';
return v + ' хв';
}, anchor: 'end', align: 'top' },
},
scales: { y: { beginAtZero: true, title: { display: true, text: 'Сер. час обороту (хв)' } } },
},
plugins: [ChartDataLabels]
});
charts.durTime = mkChart('chartDurTime', 'line', [
lineDs('Передові', segSeries('передовий', 'avg_duration'), COLORS.blue),
lineDs('Тилові', segSeries('тиловий', 'avg_duration'), COLORS.primary),
lineDs('Загалом', totSeries('avg_duration'), COLORS.red, { borderDash: [5, 5], pointRadius: 2, borderWidth: 2 }),
], MO_LABELS, baseOpts(true, { y: { beginAtZero: false } }));
quizInto('quiz-duration-spot', 'duration_paradox');
}
// --- Надсилання та підсумок ---
function submitAnswer() {
if (state.submitted) return;
state.submitted = true;
if (!(state.checked.conclusionSeasonal && state.checked.conclusionParadox)) {
state.submitted = false;
const hint = document.getElementById('submitHint'); hint.style.color = '#e65100';
hint.textContent = state.checked.conclusionSeasonal ? 'Сезонність ти впіймав. А падіння часу обороту? Дослідь далі.'
: state.checked.conclusionParadox ? 'Парадокс — чудово. Але чи падіння рейсів справді проблема? Подивись на «Огляд».'
: 'Спочатку познач свої висновки.';
return;
}
showSummary();
}
function showSummary() {
const n22 = GAME_DATA.yoy_totals.find(d => d.month === '2022-11'), n23 = GAME_DATA.yoy_totals.find(d => d.month === '2023-11');
const yoyPct = ((n23.rides - n22.rides) / n22.rides * 100).toFixed(1);
const oc = seg('2023-10', 'передовий').avg_duration, om = seg('2023-10', 'тиловий').avg_duration;
const ocShare = Math.round(seg('2023-10', 'передовий').rides / S.prior_rides * 100), ncShare = Math.round(seg('2023-11', 'передовий').rides / S.focus_rides * 100);
const answered = Object.values(state.quizzes).filter(v => v === 'correct').length;
const total = Object.keys(QUIZZES).length;
const bonus = [...state.deadEndLessons].map(k => LESSON_NAMES[k]).filter(Boolean);
document.getElementById('summaryBox').innerHTML = `
<h2>Розслідування завершено</h2>
<div class="summary-score"><span class="big">${answered}/${total} питань</span><span class="sub">правильно · ${state.wrongCount} помилкових спроб</span></div>
<div class="s-card reframe"><h3>Ти щойно врятував командира від поганого рішення</h3>
<p>Без твого аналізу командир міг би запустити непотрібне розслідування або змінити стратегію через оманливі агреговані метрики. Операційної проблеми немає — діяльність <strong>зростає рік до року</strong>.</p></div>
<div class="s-card"><h3>Висновок 1: падіння рейсів — сезонне</h3>
<p>Передові замовники (виїзди в передові порядки) зменшують замовлення, коли холоднішає. Тилові продовжують замовляти на тому ж рівні. Падіння жовтень → листопад трапляється щороку.</p>
<div class="s-metrics"><div class="s-metric"><div class="sm-label">Лис 2022</div><div class="sm-val">${fmt(n22.rides)}</div></div>
<div class="s-metric"><div class="sm-label">Лис 2023</div><div class="sm-val">${fmt(n23.rides)}</div><div class="sm-delta" style="color:#2e7d32;">${pct(parseFloat(yoyPct))} рік до року</div></div></div>
<p style="margin-top:8px;"><strong>Урок:</strong> завжди питай <em>«порівняно з чим?»</em>. Порівняння сусідніх місяців у сезонній діяльності майже завжди вводить в оману.</p></div>
<div class="s-card"><h3>Висновок 2: падіння часу обороту — парадокс Сімпсона</h3>
<p>Передові обертаються довше (~${oc} хв), ніж тилові (~${om} хв). У жовтні передові становили <strong>${ocShare}%</strong> рейсів. У листопаді — лише <strong>${ncShare}%</strong>. Загальне середнє впало, бо група з довшими рейсами стиснулася — а не тому, що хтось став обертатися швидше.</p>
<p><strong>Урок:</strong> коли композиція груп змінюється, загальне середнє рухається, навіть якщо поведінка жодної групи не змінилася. Завжди сегментуй, перш ніж робити висновок.</p></div>
<div class="s-card connection"><h3>Один корінь — два симптоми</h3>
<p>Передові скорочують рейси взимку. Ця єдина причина дала дві оманливі метрики: рейси «обвалилися» (просто сезонність) і час обороту «впав» (просто композиція). Один механізм — сезонний зсув композиції — створив дві начебто різні проблеми.</p></div>
${bonus.length ? `<div class="s-card"><h3>Уроки «глухих кутів», які ти знайшов</h3><ul class="bonus-list">${bonus.map(l => `<li>${l}</li>`).join('')}</ul></div>` : ''}
<div class="s-card discussion"><h3>Обговорення</h3>
<p>1. На яку вкладку ти зайшов першою? Що вона тобі сказала?</p><p>2. Чи «середній час обороту» — корисна метрика сама по собі? Коли вона вводить в оману?</p>
<p>3. Якби ти будував такий дашборд для бригади, як налаштував би сповіщення, щоб вони не спрацьовували щоосені?</p><p>4. Які ще метрики у твоєму повсякденні можуть страждати від ефектів композиції?</p></div>
<div class="summary-actions"><button onclick="restart()">Дослідити знову</button></div>`;
document.getElementById('summaryOverlay').classList.add('visible');
}
// --- Перезапуск ---
function restart() {
state.tabsVisited.clear(); state.subViewsVisited.clear();
state.overviewMode = 'monthly'; state.ridersMode = 'monthly'; state.currentSubTab = 'dayofweek';
state.deadEndLessons.clear(); state.submitted = false; state.wrongCount = 0; state.quizzes = {};
Object.keys(state.insights).forEach(k => state.insights[k] = false);
Object.keys(state.checked).forEach(k => state.checked[k] = false);
Object.values(charts).forEach(c => { try { c.destroy(); } catch (e) {} });
for (const k in charts) delete charts[k];
document.getElementById('summaryOverlay').classList.remove('visible');
document.getElementById('submitHint').textContent = 'Дослідь дашборд і відповідай на питання, щоб зібрати докази';
document.getElementById('submitHint').style.color = '';
renderInsightCards(); switchTab('overview'); toggleOverview('monthly');
}
// --- Ініціалізація ---
Chart.defaults.font.family = "'Source Sans Pro', sans-serif";
Chart.register(ChartDataLabels);
Chart.defaults.plugins.datalabels = { display: false };
document.getElementById('amRides').textContent = fmt(S.focus_rides);
document.getElementById('amRidesDelta').textContent = pct(S.rides_change_pct) + ' vs жовтень';
document.getElementById('amDur').textContent = S.focus_avg_duration + ' хв';
document.getElementById('amDurDelta').textContent = pct(S.duration_change_pct) + ' vs жовтень';
renderInsightCards(); renderOverview(); state.tabsVisited.add('overview'); updateViewCount();