Replace synthetic port with relabeled BUS220 source (Ukrainian, brigade logistics framing)

This commit is contained in:
2026-05-09 02:40:53 +03:00
parent 10be37e01b
commit abeb164fae
8 changed files with 445 additions and 9533 deletions
+125 -158
View File
@@ -1,11 +1,11 @@
// Game engine: state, chart rendering, quiz mechanics, tab navigation.
// Content lives in quizzes.js; raw data in game_data.js.
// Двигун гри: стан, рендеринг графіків, механіка вікторини, навігація вкладками.
// Контент — у 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) + '%';
const pct = n => (n > 0 ? '+' : '') + n.toFixed(1).replace('.', ',') + '%';
// Monthly helpers
// Helpers місяців
const ALL_MONTHS = GAME_DATA.monthly_totals.map(d => d.month);
const MO_LABELS = ALL_MONTHS.map(m => {
const [y, mo] = m.split('-');
@@ -16,9 +16,9 @@ const seg = (m, s) => GAME_DATA.monthly_by_segment.find(d => d.month === m && d.
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) + 'K' : v;
const kFmt = v => v >= 1000 ? (v / 1000).toFixed(0) + 'К' : v;
// Chart helpers
// 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 } },
@@ -29,7 +29,7 @@ const lineDs = (label, data, color, extra) => ({ label, data, borderColor: color
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]; } }
// State
// Стан
const state = {
tabsVisited: new Set(), subViewsVisited: new Set(),
overviewMode: 'monthly', ridersMode: 'monthly',
@@ -41,7 +41,7 @@ const state = {
};
const charts = {};
// --- Quiz rendering ---
// --- Рендеринг вікторини ---
function renderQuiz(quizId, container) {
const q = QUIZZES[quizId], solved = state.quizzes[quizId] === 'correct';
@@ -50,7 +50,7 @@ function renderQuiz(quizId, container) {
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 ? '\u2713' : ''}</span><span>${o.text}</span></button>`
? `<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>`;
@@ -63,7 +63,7 @@ function handleQuizChoice(quizId, optIdx) {
const btn = document.getElementById('qopt-' + quizId + '-' + optIdx);
if (opt.correct) {
state.quizzes[quizId] = 'correct';
btn.classList.add('correct'); btn.querySelector('.q-icon').textContent = '\u2713';
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);
@@ -72,7 +72,7 @@ function handleQuizChoice(quizId, optIdx) {
if (lessonEl) lessonEl.classList.remove('lesson-hidden');
} else {
state.wrongCount++;
btn.classList.add('wrong'); btn.querySelector('.q-icon').textContent = '\u2717';
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);
}
@@ -81,7 +81,7 @@ function handleQuizChoice(quizId, optIdx) {
function quizInto(spotId, quizId) { const s = document.getElementById(spotId); if (!s) return; s.innerHTML = ''; renderQuiz(quizId, s); }
// --- Insight cards ---
// --- Картки інсайтів ---
function renderInsightCards() {
document.getElementById('observationCards').innerHTML = OBSERVATIONS.map(insightCardHTML).join('');
@@ -92,29 +92,26 @@ function insightCardHTML(o) {
const isCon = o.tag === 'conclusion';
const unlocked = state.insights[o.key];
if (!unlocked) {
// Fully hidden: just a placeholder
return `<div class="insight-card locked ${isCon ? 'conclusion' : ''}" id="card-${o.key}">
<div class="insight-placeholder">\uD83D\uDD12 \u0420\u043E\u0437\u0431\u043B\u043E\u043A\u0443\u0454\u0442\u044C\u0441\u044F, \u043A\u043E\u043B\u0438 \u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u043E \u0432\u0456\u0434\u043F\u043E\u0432\u0456\u0441\u0442\u0435 \u043D\u0430 \u043F\u0438\u0442\u0430\u043D\u043D\u044F \u0443 \u0432\u043A\u043B\u0430\u0434\u0446\u0456</div>
<div class="insight-placeholder">🔒 Розблокується після відповідей на питання дашборду</div>
${isCon ? `<div class="insight-check" id="check-${o.key}"></div>` : ''}</div>`;
}
const tagLabel = { observation: '\u0441\u043f\u043e\u0441\u0442\u0435\u0440\u0435\u0436\u0435\u043d\u043d\u044f', conclusion: '\u0432\u0438\u0441\u043d\u043e\u0432\u043e\u043a' }[o.tag] || o.tag;
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] ? '\u2713' : ''}</div>` : ''}</div>`;
${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;
// Re-render all cards to update from placeholder to content
renderInsightCards();
const card = document.getElementById('card-' + key);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Conclusion unlock logic
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>`;
if (el && !el.innerHTML) el.innerHTML = `<div class="connection-card"><strong>Один корінь — два симптоми.</strong> Передові замовники перестають замовляти рейси, коли холоднішає. Це викликає і падіння кількості рейсів, і парадокс тривалості — сезонний зсув композиції тягне обидві метрики.</div>`;
}
updateSubmit();
}
@@ -122,17 +119,17 @@ 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] ? '\u2713' : '';
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 = '';
const h = document.getElementById('submitHint'); h.textContent = 'Познач висновки, які хочеш надіслати, і тисни «Надіслати»'; h.style.color = '';
}
}
// --- View tracking ---
// --- Облік переглядів ---
function recordView(viewId) {
(viewId.startsWith('sub:') ? state.subViewsVisited : state.tabsVisited).add(viewId);
@@ -141,10 +138,10 @@ function recordView(viewId) {
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} \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u0456` : '') + (w > 0 ? ` \u00b7 ${w} \u043f\u043e\u043c\u0438\u043b\u043e\u043a` : '');
document.getElementById('viewsTag').textContent = (a > 0 ? `${a}/${total} відповідей` : '') + (w > 0 ? ` · ${w} помилок` : '');
}
// --- Tab switching ---
// --- Перемикання вкладок ---
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
@@ -153,7 +150,7 @@ function switchTab(tabId) {
({ overview: renderOverview, breakdowns: renderBreakdowns, riders: renderRiders, duration: renderDuration })[tabId]();
}
// --- Overview tab ---
// --- Вкладка «Огляд» ---
function toggleOverview(mode) {
state.overviewMode = mode;
@@ -164,32 +161,31 @@ function renderOverview() {
destroyChart('overviewRides'); destroyChart('overviewDuration');
const spot = document.getElementById('quiz-overview-spot'); spot.innerHTML = '';
const monthShort = ['Січ','Лют','Бер','Кві','Тра','Чер','Лип','Сер','Вер','Жов','Лис','Гру'];
if (state.overviewMode === 'monthly') {
charts.overviewRides = mkChart('chartOverviewRides', 'line',
[lineDs('Кількість операцій', totSeries('rides'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.08)', fill: true })],
[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, ticks: { callback: v => v + '%' } } }));
[lineDs('Сер. час обороту', totSeries('avg_duration'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.08)', fill: true })],
MO_LABELS, baseOpts(false, { y: { beginAtZero: false } }));
} else {
// Daily view
const daily = GAME_DATA.daily_totals;
const labels = daily.map((d) => {
const labels = daily.map((d, i) => {
const dt = new Date(d.date);
return (dt.getDate() === 1) ? monthShort[dt.getMonth()] + " '" + String(dt.getFullYear()).slice(2) : '';
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 }],
[{ 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, ticks: { callback: v => v + '%' } }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
[{ 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);
}
// --- Breakdowns tab ---
// --- Вкладка «Розрізи» ---
function switchSubTab(subId) {
state.currentSubTab = subId;
@@ -199,33 +195,33 @@ function switchSubTab(subId) {
function renderBreakdowns() {
const c = document.getElementById('breakdowns-content'); c.innerHTML = '';
destroyChart('bd1'); destroyChart('bd2');
({ dayofweek: renderDayOfWeek, hourly: renderHourly, biketypes: renderBikeTypes, duration_buckets: renderDurationBuckets, stations: renderStations })[state.currentSubTab](c);
({ dayofweek: renderDayOfWeek, hourly: renderHourly, vehicletypes: renderVehicleTypes, duration_buckets: renderDurationBuckets, stations: renderStations })[state.currentSubTab](c);
}
function renderDayOfWeek(container) {
const days = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
const dayLabels = { Monday:'Пн', Tuesday:'Вт', Wednesday:'Ср', Thursday:'Чт', Friday:'Пт', Saturday:'Сб', Sunday:'Нд' };
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 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>Порівнюючи місяці, перевір спершу їхню *календарну структуру*: скільки в кожному кожного дня тижня. Місяці з різною кількістю буднів і вихідних можуть створювати фальшиві патерни в "сирих" сумах.</p></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: days.map(d => dayLabels[d]),
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: days.map(d => dayLabels[d]),
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) + '%', anchor: 'end', align: 'top' } }, scales: { y: { ticks: { callback: v => v + '%' } } } },
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');
@@ -237,9 +233,9 @@ function renderHourly(container) {
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 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>`;
<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: [
@@ -250,66 +246,47 @@ function renderHourly(container) {
quizInto('quiz-hourly-spot', 'hourly');
}
function renderBikeTypes(container) {
// Fixed order for narrative: prep first (training, logistics), then combat (recon, fire_support, defensive)
const types = ['training', 'logistics', 'recon', 'fire_support', 'defensive'];
const typeLabels = {
training: 'Тренування', logistics: 'Логістика',
recon: 'Розвідка', fire_support: 'Вогневе ураження', defensive: 'Оборонні дії',
};
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 octEff = types.map(t => { const r = GAME_DATA.bike_type.find(x => x.month === '2023-10' && x.bike_type === t); return r ? r.avg_duration : 0; });
const novEff = types.map(t => { const r = GAME_DATA.bike_type.find(x => x.month === '2023-11' && x.bike_type === t); return r ? r.avg_duration : 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 class="chart-card"><h3>Ефективність (% успіху) за категорією</h3><canvas id="chartBd2"></canvas></div>
<div id="quiz-biketypes-spot"></div>
<div class="lesson-card lesson-hidden" id="lesson-biketypes"><h4>Аналітичний урок</h4><p>Коли категорії рухаються в *різні* боки — це сигнал структурної зміни. Подивись на частки до й після, не лише на абсолютні цифри. *Кожна категорія окремо* може бути стабільною, а загальна — рухатися лише через зміну ваг.</p></div>`;
<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.map(t => typeLabels[t]),
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) + '%'; return ''; }, anchor: 'end', align: 'top' } }, scales: { y: { beginAtZero: true, ticks: { callback: kFmt } } } },
]}, 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]
});
charts.bd2 = new Chart(document.getElementById('chartBd2'), {
type: 'bar', data: { labels: types.map(t => typeLabels[t]),
datasets: [
{ label: 'Жовтень', data: octEff, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novEff, backgroundColor: COLORS.blue },
]}, options: { plugins: { legend: { position: 'top', labels: { boxWidth: 14 } }, datalabels: { display: true, color: '#333', font: { size: 10 }, formatter: v => v.toFixed(1) + '%', anchor: 'end', align: 'top' } }, scales: { y: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } } } },
plugins: [ChartDataLabels]
});
quizInto('quiz-biketypes-spot', 'biketypes');
quizInto('quiz-vehicletypes-spot', 'vehicletypes');
}
function renderDurationBuckets(container) {
const buckets = ['small', 'medium', 'large'];
const bucketLabels = { small: 'Мала', medium: 'Середня', large: 'Велика' };
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>
<div class="lesson-card lesson-hidden" id="lesson-duration_buckets"><h4>Аналітичний урок</h4><p>Якщо різні масштаби операцій рухаються в один бік приблизно однаково — це не пояснення зміни. Шукай розрізи, де *відсоткові зміни розходяться*.</p></div>`;
<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>`;
const bucketDisplayLabels = buckets.map(b => bucketLabels[b]);
charts.bd1 = new Chart(document.getElementById('chartBd1'), {
type: 'bar', data: { labels: bucketDisplayLabels,
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) + '%', anchor: 'end', align: 'top' } }, scales: { y: { ticks: { callback: v => v + '%' } } } },
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: bucketDisplayLabels,
type: 'bar', data: { labels: buckets,
datasets: [
{ label: 'Жовтень', data: octData, backgroundColor: COLORS.primary },
{ label: 'Листопад', data: novData, backgroundColor: COLORS.blue },
@@ -322,20 +299,20 @@ function renderStations(container) {
const data = GAME_DATA.station_comparison.slice(0, 10);
container.innerHTML = `
<div class="chart-card"><h3>Підрозділи — % зміни обсягу (жовтень &rarr; листопад)</h3><canvas id="chartBd1"></canvas></div>
<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>Коли усі підрозділи рухаються в один бік приблизно однаково — причина системна, не локальна. Не шукай "винного" — шукай фактор, що вплинув на всіх.</p></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.length > 25 ? d.station.substring(0, 25) + '\u2026' : d.station),
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) + '%' } }, datalabels: { display: true, anchor: 'end', align: 'right', color: '#333', font: { weight: 'bold', size: 11 }, formatter: v => v.toFixed(1) + '%' } }, scales: { x: { ticks: { callback: v => v + '%' } } } },
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');
}
// --- Rider Types tab ---
// --- Вкладка «Замовники» ---
function toggleRiders(mode) {
state.ridersMode = mode;
@@ -346,64 +323,61 @@ function renderRiders() {
destroyChart('riderRides'); destroyChart('riderMix');
const spot = document.getElementById('quiz-riders-spot'); spot.innerHTML = '';
const monthShortR = ['Січ','Лют','Бер','Кві','Тра','Чер','Лип','Сер','Вер','Жов','Лис','Гру'];
if (state.ridersMode === 'monthly') {
charts.riderRides = mkChart('chartRiderRides', 'line', [
lineDs('Підготовчі', segSeries('preparation', 'rides'), COLORS.primary, { backgroundColor: 'rgba(0,57,100,0.06)', fill: true }),
lineDs('Бойові', segSeries('combat', 'rides'), COLORS.blue, { backgroundColor: 'rgba(0,187,206,0.06)', fill: true }),
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));
// Show share of preparation (the GROWING segment) — highlights the mix shift visually
const shares = ALL_MONTHS.map(m => {
const p = seg(m, 'preparation').rides || 0, t = tot(m).rides || 1;
return Math.round(p / t * 100);
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.primary, backgroundColor: 'rgba(0,57,100,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: 0, max: 70, ticks: { callback: v => v + '%' } } } },
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 {
// Daily view by segment
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 ? monthShortR[dt.getMonth()] + " '" + String(dt.getFullYear()).slice(2) : '';
return dt.getDate() === 1 ? monthNames[dt.getMonth()] + ' ' + String(dt.getFullYear()).slice(2) : '';
});
const prepRides = dates.map(d => { const r = daily.find(x => x.date === d && x.segment === 'preparation'); return r ? r.rides : 0; });
const combatRides = dates.map(d => { const r = daily.find(x => x.date === d && x.segment === 'combat'); return r ? r.rides : 0; });
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: prepRides, borderColor: COLORS.primary, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,57,100,0.06)' },
{ label: 'Бойові', data: combatRides, borderColor: COLORS.blue, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,187,206,0.06)' },
{ 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 } } } });
// Daily preparation share
const shareData = dates.map(d => {
const p = daily.find(x => x.date === d && x.segment === 'preparation');
const c = daily.find(x => x.date === d && x.segment === 'combat');
const pv = p ? p.rides : 0, cv = c ? c.rides : 0;
return pv + cv > 0 ? Math.round(pv / (pv + cv) * 100) : 0;
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.primary, tension: 0.1, pointRadius: 0, borderWidth: 1.5, fill: true, backgroundColor: 'rgba(0,57,100,0.1)' }],
labels, { ...baseOpts(false), scales: { y: { min: 0, max: 70, ticks: { callback: v => v + '%' } }, x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 14 } } } });
[{ 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('riders_reveal', spot);
renderQuiz('requesters_reveal', spot);
renderQuiz('requesters_mix', spot);
}
// --- Duration tab ---
// --- Вкладка «Час обороту» ---
function renderDuration() {
destroyChart('durChange'); destroyChart('durTime');
const oc = seg('2023-10', 'combat').avg_duration, nc = seg('2023-11', 'combat').avg_duration;
const om = seg('2023-10', 'preparation').avg_duration, nm = seg('2023-11', 'preparation').avg_duration;
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;
// Vertical grouped bar — Oct and Nov side by side for each group, plus overall
const groups = ['Бойові', 'Підготовчі', 'Загалом'];
const groups = ['Передові', 'Тилові', 'Загалом'];
const octVals = [oc, om, S.prior_avg_duration];
const novVals = [nc, nm, S.focus_avg_duration];
const changePcts = [cc, cm, ca];
@@ -420,30 +394,25 @@ function renderDuration() {
datalabels: { display: true, color: '#333', font: { size: 11, weight: 'bold' },
formatter: (v, ctx) => {
const i = ctx.dataIndex;
if (ctx.datasetIndex === 1) return v.toFixed(1) + '% (' + (changePcts[i] >= 0 ? '+' : '') + changePcts[i].toFixed(1) + ' п.п.)';
return v.toFixed(1) + '%';
if (ctx.datasetIndex === 1) return v + ' хв (' + changePcts[i].toFixed(1).replace('.', ',') + '%)';
return v + ' хв';
}, anchor: 'end', align: 'top' },
},
scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: '% успіху' }, ticks: { callback: v => v + '%' } } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Сер. час обороту (хв)' } } },
},
plugins: [ChartDataLabels]
});
// Efficiency over time with all months — per segment + overall
charts.durTime = mkChart('chartDurTime', 'line', [
lineDs('Бойові', segSeries('combat', 'avg_duration'), COLORS.blue),
lineDs('Підготовчі', segSeries('preparation', 'avg_duration'), COLORS.primary),
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, ticks: { callback: v => v + '%' } } }));
], MO_LABELS, baseOpts(true, { y: { beginAtZero: false } }));
// Show mix quiz first (now student can see per-group durations), then the composition effect
const spot = document.getElementById('quiz-duration-spot');
spot.innerHTML = '';
renderQuiz('riders_mix', spot);
renderQuiz('duration_paradox', spot);
quizInto('quiz-duration-spot', 'duration_paradox');
}
// --- Submit & summary ---
// --- Надсилання та підсумок ---
function submitAnswer() {
if (state.submitted) return;
@@ -451,9 +420,9 @@ function submitAnswer() {
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 ? 'Зміну композиції знайшли. А падіння обсягу — це справді проблема? Поверніться до Загального огляду.'
: 'Спочатку оберіть висновки, які хочете надіслати.';
hint.textContent = state.checked.conclusionSeasonal ? 'Сезонність ти впіймав. А падіння часу обороту? Дослідь далі.'
: state.checked.conclusionParadox ? 'Парадокс — чудово. Але чи падіння рейсів справді проблема? Подивись на «Огляд».'
: 'Спочатку познач свої висновки.';
return;
}
showSummary();
@@ -461,36 +430,36 @@ function submitAnswer() {
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', 'combat').avg_duration, om = seg('2023-10', 'preparation').avg_duration;
const ocShare = Math.round(seg('2023-10', 'combat').rides / S.prior_rides * 100), ncShare = Math.round(seg('2023-11', 'combat').rides / S.focus_rides * 100);
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>\u0420\u043e\u0437\u0441\u043b\u0456\u0434\u0443\u0432\u0430\u043d\u043d\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e</h2>
<div class="summary-score"><span class="big">${answered}/${total} \u043f\u0438\u0442\u0430\u043d\u044c</span><span class="sub">\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u00b7 ${state.wrongCount} \u043f\u043e\u043c\u0438\u043b\u043a\u043e\u0432\u0438\u0445 \u0441\u043f\u0440\u043e\u0431</span></div>
<div class="s-card reframe"><h3>\u0420\u0435\u0430\u043b\u044c\u043d\u043e\u0457 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439\u043d\u043e\u0457 "\u043f\u0435\u0440\u0435\u043c\u043e\u0433\u0438" \u043d\u0435\u043c\u0430\u0454</h3>
<p>\u0411\u0435\u0437 \u0441\u0435\u0433\u043c\u0435\u043d\u0442\u0430\u0446\u0456\u0457 \u043a\u043e\u043c\u0430\u043d\u0434\u0438\u0440 \u043c\u0456\u0433 \u0437\u0440\u043e\u0431\u0438\u0442\u0438 \u0432\u0438\u0441\u043d\u043e\u0432\u043e\u043a \u043f\u0440\u043e \u0440\u0435\u0430\u043b\u044c\u043d\u0435 \u043f\u043e\u043a\u0440\u0430\u0449\u0435\u043d\u043d\u044f \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0456 \u043e\u043c\u0430\u043d\u043b\u0438\u0432\u043e\u0457 \u0430\u0433\u0440\u0435\u0433\u043e\u0432\u0430\u043d\u043e\u0457 \u0446\u0438\u0444\u0440\u0438. \u041d\u0430\u0441\u043f\u0440\u0430\u0432\u0434\u0456 \u2014 \u043a\u043e\u0436\u0435\u043d \u0441\u0435\u0433\u043c\u0435\u043d\u0442 \u043e\u043a\u0440\u0435\u043c\u043e \u0441\u0442\u0430\u0431\u0456\u043b\u044c\u043d\u0438\u0439; \u0440\u0443\u0445\u0430\u0454\u0442\u044c\u0441\u044f \u043b\u0438\u0448\u0435 \u043a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0456\u044f.</p></div>
<div class="s-card"><h3>\u0412\u0438\u0441\u043d\u043e\u0432\u043e\u043a 1: \u043f\u0430\u0434\u0456\u043d\u043d\u044f \u043e\u0431\u0441\u044f\u0433\u0443 \u2014 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u043d\u0438\u0439 \u0437\u0441\u0443\u0432</h3>
<p>\u0411\u043e\u0439\u043e\u0432\u0456 \u043f\u0456\u0434\u0440\u043e\u0437\u0434\u0456\u043b\u0438 (\u043e\u0441\u043e\u0431\u043b\u0438\u0432\u043e \u043e\u0431\u043e\u0440\u043e\u043d\u043d\u0456) \u0441\u043a\u043e\u0440\u043e\u0442\u0438\u043b\u0438 \u0430\u043a\u0442\u0438\u0432\u043d\u0456\u0441\u0442\u044c \u2014 \u0447\u0430\u0441\u0442\u043a\u0430 \u0432\u043f\u0430\u043b\u0430 \u0437 ~85% \u0434\u043e ~50%. \u041f\u0456\u0434\u0433\u043e\u0442\u043e\u0432\u0447\u0456 (\u0442\u0440\u0435\u043d\u0443\u0432\u0430\u043d\u043d\u044f, \u043b\u043e\u0433\u0456\u0441\u0442\u0438\u043a\u0430) \u043d\u0430\u0442\u043e\u043c\u0456\u0441\u0442\u044c *\u0432\u0438\u0440\u043e\u0441\u043b\u0438* \u0432 \u043e\u0431\u0441\u044f\u0437\u0456. \u0426\u0435 \u043d\u0435 \u043f\u0440\u043e\u0441\u0442\u043e "\u0441\u0442\u0430\u043b\u043e \u043c\u0435\u043d\u0448\u0435" \u2014 \u0446\u0435 \u043f\u0435\u0440\u0435\u0440\u043e\u0437\u043f\u043e\u0434\u0456\u043b.</p>
<div class="s-metrics"><div class="s-metric"><div class="sm-label">\u041b\u0438\u0441 2022</div><div class="sm-val">${fmt(n22.rides)}</div></div>
<div class="s-metric"><div class="sm-label">\u041b\u0438\u0441 2023</div><div class="sm-val">${fmt(n23.rides)}</div><div class="sm-delta" style="color:#2e7d32;">${pct(parseFloat(yoyPct))} YoY</div></div></div>
<p style="margin-top:8px;"><strong>\u0423\u0440\u043e\u043a:</strong> \u0437\u0430\u0432\u0436\u0434\u0438 \u043f\u0438\u0442\u0430\u0439 <em>"\u043f\u043e\u0440\u0456\u0432\u043d\u044f\u043d\u043e \u0437 \u0447\u0438\u043c?"</em>. \u0406 \u043a\u043e\u043b\u0438 \u0446\u0438\u0444\u0440\u0430 \u0440\u0443\u0445\u0430\u0454\u0442\u044c\u0441\u044f \u2014 \u0441\u043f\u0435\u0440\u0448\u0443 \u043f\u0435\u0440\u0435\u0432\u0456\u0440, \u0447\u0438 \u0440\u0443\u0445\u0430\u0454\u0442\u044c\u0441\u044f \u043a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0456\u044f.</p></div>
<div class="s-card"><h3>\u0412\u0438\u0441\u043d\u043e\u0432\u043e\u043a 2: \u0437\u0440\u043e\u0441\u0442\u0430\u043d\u043d\u044f \u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0456 \u2014 \u0435\u0444\u0435\u043a\u0442 \u043a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0456\u0457</h3>
<p>\u041f\u0456\u0434\u0433\u043e\u0442\u043e\u0432\u0447\u0456 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0457 \u043c\u0430\u044e\u0442\u044c \u0432\u0438\u0449\u0443 \u0431\u0430\u0437\u043e\u0432\u0443 \u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u0456\u0441\u0442\u044c (~${om.toFixed(1)}%), \u0431\u043e\u0439\u043e\u0432\u0456 \u2014 \u043d\u0438\u0436\u0447\u0443 (~${oc.toFixed(1)}%). \u0423 \u0436\u043e\u0432\u0442\u043d\u0456 \u043f\u0456\u0434\u0433\u043e\u0442\u043e\u0432\u0447\u0438\u0445 \u0431\u0443\u043b\u043e \u043b\u0438\u0448\u0435 <strong>${ocShare}%</strong> \u0432\u0456\u0434 \u0437\u0430\u0433\u0430\u043b\u0443, \u0443 \u043b\u0438\u0441\u0442\u043e\u043f\u0430\u0434\u0456 \u2014 \u0432\u0436\u0435 <strong>${ncShare}%</strong>. \u0417\u0430\u0433\u0430\u043b\u044c\u043d\u0430 \u0446\u0438\u0444\u0440\u0430 \u0437\u0440\u043e\u0441\u043b\u0430 \u043b\u0438\u0448\u0435 \u0442\u043e\u043c\u0443, \u0449\u043e \u0447\u0430\u0441\u0442\u043a\u0430 "\u043b\u0435\u0433\u0448\u0438\u0445" \u043c\u0456\u0441\u0456\u0439 \u0437\u0431\u0456\u043b\u044c\u0448\u0438\u043b\u0430\u0441\u044c \u2014 \u0430 \u043d\u0435 \u0442\u043e\u043c\u0443, \u0449\u043e \u0445\u0442\u043e\u0441\u044c \u0441\u0442\u0430\u0432 \u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u0456\u0448\u0438\u043c.</p>
<p><strong>\u0423\u0440\u043e\u043a:</strong> \u043a\u043e\u043b\u0438 \u0437\u043c\u0456\u043d\u044e\u0454\u0442\u044c\u0441\u044f \u043c\u0456\u043a\u0441 \u0433\u0440\u0443\u043f, \u0430\u0433\u0440\u0435\u0433\u0430\u0442 \u0440\u0443\u0445\u0430\u0454\u0442\u044c\u0441\u044f \u043d\u0430\u0432\u0456\u0442\u044c \u044f\u043a\u0449\u043e \u0436\u043e\u0434\u043d\u0430 \u0433\u0440\u0443\u043f\u0430 \u043d\u0435 \u0437\u043c\u0456\u043d\u0438\u043b\u0430 \u043f\u043e\u0432\u0435\u0434\u0456\u043d\u043a\u0438. \u0417\u0430\u0432\u0436\u0434\u0438 \u0441\u0435\u0433\u043c\u0435\u043d\u0442\u0443\u0439, \u043f\u0435\u0440\u0448 \u043d\u0456\u0436 \u0440\u043e\u0431\u0438\u0442\u0438 \u0432\u0438\u0441\u043d\u043e\u0432\u043e\u043a.</p></div>
<div class="s-card connection"><h3>\u041e\u0434\u043d\u0430 \u043f\u0440\u0438\u0447\u0438\u043d\u0430 \u2014 \u0434\u0432\u0430 \u0441\u0438\u043c\u043f\u0442\u043e\u043c\u0438</h3>
<p>\u0411\u043e\u0439\u043e\u0432\u0456 \u043f\u0456\u0434\u0440\u043e\u0437\u0434\u0456\u043b\u0438 \u043d\u0430 \u0440\u043e\u0442\u0430\u0446\u0456\u0457/\u043f\u0456\u0434\u0433\u043e\u0442\u043e\u0432\u0446\u0456. \u0426\u0435 \u043f\u043e\u044f\u0441\u043d\u044e\u0454 \u0456 \u043f\u0430\u0434\u0456\u043d\u043d\u044f \u043e\u0431\u0441\u044f\u0433\u0443 \u043e\u043f\u0435\u0440\u0430\u0446\u0456\u0439, \u0456 \u0437\u0440\u043e\u0441\u0442\u0430\u043d\u043d\u044f \u0437\u0430\u0433\u0430\u043b\u044c\u043d\u043e\u0457 \u0446\u0438\u0444\u0440\u0438 \u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0456 \u2014 \u043e\u0431\u0438\u0434\u0432\u0430 \u0435\u0444\u0435\u043a\u0442\u0438 \u0432\u0456\u0434 \u043e\u0434\u043d\u043e\u0433\u043e \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u043d\u043e\u0433\u043e \u0437\u0441\u0443\u0432\u0443.</p></div>
${bonus.length ? `<div class="s-card"><h3>\u0423\u0440\u043e\u043a\u0438 \u043d\u0430 \u0442\u0443\u043f\u0438\u043a\u043e\u0432\u0438\u0445 \u0433\u0456\u043b\u043a\u0430\u0445</h3><ul class="bonus-list">${bonus.map(l => `<li>${l}</li>`).join('')}</ul></div>` : ''}
<div class="s-card discussion"><h3>\u041e\u0431\u0433\u043e\u0432\u043e\u0440\u0435\u043d\u043d\u044f</h3>
<p>1. \u041d\u0430 \u044f\u043a\u0443 \u0432\u043a\u043b\u0430\u0434\u043a\u0443 \u0432\u0438 \u043f\u0456\u0448\u043b\u0438 \u043f\u0435\u0440\u0448\u043e\u044e? \u0429\u043e \u0432\u043e\u043d\u0430 \u0432\u0430\u043c \u043f\u043e\u043a\u0430\u0437\u0430\u043b\u0430?</p><p>2. \u042f\u043a\u0431\u0438 \u0432 \u0434\u0430\u0448\u0431\u043e\u0440\u0434\u0456 \u0431\u0443\u043b\u0430 \u0442\u0456\u043b\u044c\u043a\u0438 \u043e\u0434\u043d\u0430 \u0437\u0430\u0433\u0430\u043b\u044c\u043d\u0430 \u0446\u0438\u0444\u0440\u0430 \u2014 \u0432\u0438 \u0431 \u043f\u043e\u043c\u0456\u0442\u0438\u043b\u0438 \u0437\u0441\u0443\u0432 \u043a\u043e\u043c\u043f\u043e\u0437\u0438\u0446\u0456\u0457?</p>
<p>3. \u0423 \u0432\u0430\u0448\u0456\u0439 \u0440\u0435\u0430\u043b\u044c\u043d\u0456\u0439 \u0440\u043e\u0431\u043e\u0442\u0456 \u2014 \u044f\u043a\u0456 \u043f\u043e\u043a\u0430\u0437\u043d\u0438\u043a\u0438 \u043d\u0430\u0439\u0431\u0456\u043b\u044c\u0448 \u0432\u0440\u0430\u0437\u043b\u0438\u0432\u0456 \u0434\u043e \u0442\u0430\u043a\u0438\u0445 \u0435\u0444\u0435\u043a\u0442\u0456\u0432?</p><p>4. \u0429\u043e \u0431 \u0432\u0438 \u0441\u043a\u0430\u0437\u0430\u043b\u0438 \u043a\u043e\u043c\u0430\u043d\u0434\u0438\u0440\u043e\u0432\u0456, \u044f\u043a\u0431\u0438 \u0432\u0456\u043d \u043f\u0440\u043e\u044f\u0432\u0438\u0432 \u0437\u0430\u0446\u0456\u043a\u0430\u0432\u043b\u0435\u043d\u043d\u044f \u043b\u0438\u0448\u0435 \u0446\u0438\u0444\u0440\u043e\u044e 71%?</p></div>
<div class="summary-actions"><button onclick="restart()">\u0414\u043e\u0441\u043b\u0456\u0434\u0438\u0442\u0438 \u0437\u0430\u043d\u043e\u0432\u043e</button></div>`;
<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');
}
// --- Restart ---
// --- Перезапуск ---
function restart() {
state.tabsVisited.clear(); state.subViewsVisited.clear();
@@ -501,20 +470,18 @@ function restart() {
Object.values(charts).forEach(c => { try { c.destroy(); } catch (e) {} });
for (const k in charts) delete charts[k];
document.getElementById('summaryOverlay').classList.remove('visible');
const intro = document.getElementById('introOverlay');
if (intro) intro.classList.remove('hidden');
document.getElementById('submitHint').textContent = 'Пройдіть вкладки і відповідайте на питання, щоб зібрати докази';
document.getElementById('submitHint').textContent = 'Дослідь дашборд і відповідай на питання, щоб зібрати докази';
document.getElementById('submitHint').style.color = '';
renderInsightCards(); switchTab('overview'); toggleOverview('monthly');
}
// --- Init ---
// --- Ініціалізація ---
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) + ' до жовтня';
document.getElementById('amDur').textContent = S.focus_avg_duration.toFixed(1) + '%';
document.getElementById('amDurDelta').textContent = (S.duration_change_pct >= 0 ? '+' : '') + S.duration_change_pct.toFixed(1) + ' п.п. до жовтня';
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();