mirror of
https://github.com/olehomelchenko/bi-detective.git
synced 2026-06-23 21:27:47 +00:00
Replace synthetic port with relabeled BUS220 source (Ukrainian, brigade logistics framing)
This commit is contained in:
@@ -1,34 +1,35 @@
|
||||
# BI Detective
|
||||
# BI-детектив: Розслідування дашборду
|
||||
|
||||
Standalone static-site exercise for a data-literacy lecture (ХАРТІЯ × KSE GBS, 9 травня 2026).
|
||||
Статичний навчальний дашборд українською мовою для лекції з дата-грамотності (ХАРТІЯ × KSE GBS).
|
||||
|
||||
VP message says brigade efficiency went 62% → 71% over a month. Player has 4 dashboard tabs and 9 multiple-choice quizzes to figure out *why* — and whether the headline number actually means what it looks like (mission-mix-shift / Simpson's paradox).
|
||||
Командир логістики бачить, що рейсів стало приблизно на 30% менше за місяць (жовтень → листопад) і просить пояснити це до завтрашнього брифінгу. Гравець має 4 вкладки дашборду й 9 питань, щоб з'ясувати, *що насправді сталося* — і чи проблема справжня (відповідь: ні; це сезонність + парадокс Сімпсона через зсув композиції замовників).
|
||||
|
||||
Synthetic dataset of 8 540 ops; UI in Ukrainian; no backend.
|
||||
> **Дані синтетичні й абстрактні.** Завдання — шукати закономірності у самих даних, а не виводити їх із реального світу. Тут працюють лише докази на основі цифр.
|
||||
|
||||
## Run locally
|
||||
9 питань тестують специфічні рефлекси читання даних:
|
||||
|
||||
Serve the directory with any static HTTP server, e.g.:
|
||||
1. «Порівняно з чим?» — рік до року
|
||||
2. Календарні артефакти (4 vs 5 днів тижня)
|
||||
3. Зсув обсягу vs зсув патерну (погодинно)
|
||||
4. Чи розходяться значення в розрізі (клас техніки)
|
||||
5. Кошики тривалості — провідна підказка
|
||||
6. Системний vs локальний (логістичні вузли)
|
||||
7. Композиція замовників — провідна стежка
|
||||
8. Зсув композиції впливає на середнє
|
||||
9. Парадокс Сімпсона — фінал
|
||||
|
||||
## Запуск локально
|
||||
|
||||
```
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000`.
|
||||
Далі відкрий `http://localhost:8000` у браузері.
|
||||
|
||||
## Regenerate the dataset
|
||||
## Файли
|
||||
|
||||
```
|
||||
python3 generate_data.py
|
||||
```
|
||||
|
||||
Rewrites `game_data.js` (and `raw_facts.csv`). Headline calibrates to ≈ 62% → 71%; per-category success rates rock-stable across runs.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` — main UI
|
||||
- `game.js` — game logic
|
||||
- `game_data.js` — pre-baked synthetic dataset (8 540 ops)
|
||||
- `quizzes.js` — 9 Ukrainian-language quizzes
|
||||
- `generate_data.py` — dataset generator (regenerates `game_data.js` + `raw_facts.csv`)
|
||||
- `raw_facts.csv` — intermediate flat data, kept for inspection
|
||||
- `index.html` — UI
|
||||
- `game.js` — логіка гри
|
||||
- `game_data.js` — JSON-структуровані дані, виведені з реального датасету Divvy (Chicago bikeshare) і перейменовані в логістичну термінологію
|
||||
- `quizzes.js` — 9 питань-кейсів
|
||||
- `styles.css` — стилі
|
||||
|
||||
@@ -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>% зміни по дням (жовтень → листопад)</h3><canvas id="chartBd2"></canvas></div>
|
||||
<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-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>% зміни за масштабом операцій (жовтень → листопад)</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>% зміни за кошиком тривалості (жовтень → листопад)</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>Підрозділи — % зміни обсягу (жовтень → листопад)</h3><canvas id="chartBd1"></canvas></div>
|
||||
<div class="chart-card"><h3>Топ-10 вузлів — % зміни (жовтень → листопад)</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();
|
||||
|
||||
+2
-10
File diff suppressed because one or more lines are too long
@@ -1,446 +0,0 @@
|
||||
"""
|
||||
Generate synthetic operations data for the BI Detective L5 game.
|
||||
|
||||
Single fact table (one row per operation) → aggregated views matching the shape
|
||||
the BUS220 game.js expects (rides → ops count, avg_duration → success rate %).
|
||||
|
||||
Story:
|
||||
- Headline efficiency 62% (Oct 2023) → 71% (Nov 2023). Volume 800 → 500 ops.
|
||||
- Volume drop is seasonal (Nov 2022 had similar volume → YoY check defuses it).
|
||||
- Efficiency rise is a MIX SHIFT: brigade pulled defensive units for refit, share
|
||||
of "preparation" missions (training + logistics) grew from 15% → 50%; "combat"
|
||||
missions (recon + fire support + defensive) shrank 85% → 50%.
|
||||
- Per-category success rates are STABLE across all months; aggregate rises only
|
||||
because composition shifted toward easier categories.
|
||||
|
||||
Output: game_data.js (JS file with `const GAME_DATA = {...}`) + raw_facts.csv
|
||||
(per-op fact table, for inspection/audit).
|
||||
|
||||
Run: python3 generate_data.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
OUT_DIR = Path(__file__).parent
|
||||
|
||||
# --- Domain ---
|
||||
|
||||
CATEGORIES = ["training", "logistics", "recon", "fire_support", "defensive"]
|
||||
CATEGORY_LABEL_UK = {
|
||||
"training": "Тренування",
|
||||
"logistics": "Логістика",
|
||||
"recon": "Розвідка",
|
||||
"fire_support": "Вогневе ураження",
|
||||
"defensive": "Оборонні дії",
|
||||
}
|
||||
# Stable across all months (the whole point — only composition shifts)
|
||||
CATEGORY_BASE_SUCCESS = {
|
||||
"training": 0.91,
|
||||
"logistics": 0.79,
|
||||
"recon": 0.67,
|
||||
"fire_support": 0.60,
|
||||
"defensive": 0.50,
|
||||
}
|
||||
|
||||
# 2-segment partition for the "Rider Types" tab analog
|
||||
SEGMENT_OF = {
|
||||
"training": "preparation",
|
||||
"logistics": "preparation",
|
||||
"recon": "combat",
|
||||
"fire_support": "combat",
|
||||
"defensive": "combat",
|
||||
}
|
||||
|
||||
UNITS = ["1 БТГр", "2 БТГр", "3 БТГр", "Розвідрота", "Інж-сап. рота"]
|
||||
SCALES = ["small", "medium", "large"]
|
||||
SCALE_ORDER = {"small": 1, "medium": 2, "large": 3}
|
||||
SCALE_LABEL_UK = {"small": "Мала", "medium": "Середня", "large": "Велика"}
|
||||
|
||||
# Per-month totals (ops count) — seasonal pattern, calibrated so:
|
||||
# - Oct 2023 = 800 (prior month, "normal")
|
||||
# - Nov 2023 = 500 (focus month, sharp drop)
|
||||
# - Nov 2022 = 480 (similar seasonal pattern → YoY check defuses volume alarm)
|
||||
MONTHLY_OPS = {
|
||||
"2022-11": 480,
|
||||
"2022-12": 460,
|
||||
"2023-01": 470,
|
||||
"2023-02": 500,
|
||||
"2023-03": 600,
|
||||
"2023-04": 700,
|
||||
"2023-05": 750,
|
||||
"2023-06": 820,
|
||||
"2023-07": 850,
|
||||
"2023-08": 830,
|
||||
"2023-09": 780,
|
||||
"2023-10": 800,
|
||||
"2023-11": 500,
|
||||
}
|
||||
|
||||
# Category share of total ops per month.
|
||||
# All months use the "old" mix EXCEPT Nov 2023 — that's the one with the new pattern.
|
||||
OLD_MIX = {
|
||||
"training": 0.05, "logistics": 0.10,
|
||||
"recon": 0.28, "fire_support": 0.27, "defensive": 0.30,
|
||||
}
|
||||
NEW_MIX = { # Nov 2023 only — defensive units pulled for refit, prep grew
|
||||
"training": 0.16, "logistics": 0.36,
|
||||
"recon": 0.15, "fire_support": 0.16, "defensive": 0.17,
|
||||
}
|
||||
|
||||
def category_share(month: str) -> dict[str, float]:
|
||||
return NEW_MIX if month == "2023-11" else OLD_MIX
|
||||
|
||||
# Mission scale distribution (stable, doesn't drive the story — red herring)
|
||||
SCALE_SHARE = {"small": 0.55, "medium": 0.30, "large": 0.15}
|
||||
|
||||
# Hourly distribution: bell-shaped around midday/afternoon (typical activity window)
|
||||
# Used as weights for sampling.
|
||||
HOURLY_WEIGHT = [
|
||||
0.5, 0.3, 0.2, 0.2, 0.3, 0.5, # 0-5
|
||||
0.8, 1.5, 2.5, 3.5, 4.0, 4.0, # 6-11
|
||||
3.5, 3.5, 4.0, 4.5, 4.5, 4.0, # 12-17
|
||||
3.5, 2.5, 1.8, 1.2, 0.8, 0.5, # 18-23
|
||||
]
|
||||
|
||||
# --- Generation ---
|
||||
|
||||
@dataclass
|
||||
class Op:
|
||||
date: str
|
||||
month: str
|
||||
weekday: str
|
||||
weekday_num: int # 0 = Sunday (matches Divvy convention)
|
||||
hour: int
|
||||
unit: str
|
||||
category: str
|
||||
segment: str
|
||||
scale: str
|
||||
succeeded: bool
|
||||
|
||||
WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||
|
||||
def days_in_month(year: int, month: int) -> list[date]:
|
||||
d = date(year, month, 1)
|
||||
days = []
|
||||
while d.month == month:
|
||||
days.append(d)
|
||||
d += timedelta(days=1)
|
||||
return days
|
||||
|
||||
def weighted_choice(weights: dict[str, float]) -> str:
|
||||
keys = list(weights.keys())
|
||||
vals = [weights[k] for k in keys]
|
||||
return random.choices(keys, weights=vals, k=1)[0]
|
||||
|
||||
def weighted_int(weights: list[float]) -> int:
|
||||
return random.choices(range(len(weights)), weights=weights, k=1)[0]
|
||||
|
||||
def exact_counts(total: int, shares: dict[str, float]) -> dict[str, int]:
|
||||
"""Return exact integer counts per key that sum to total, distributed by shares."""
|
||||
raw = {k: total * v for k, v in shares.items()}
|
||||
counts = {k: int(v) for k, v in raw.items()}
|
||||
deficit = total - sum(counts.values())
|
||||
# Hand the leftover units to the keys with the largest fractional remainders.
|
||||
fracs = sorted(((raw[k] - counts[k], k) for k in shares), reverse=True)
|
||||
for i in range(deficit):
|
||||
counts[fracs[i % len(fracs)][1]] += 1
|
||||
return counts
|
||||
|
||||
def generate_ops(seed: int = 42) -> list[Op]:
|
||||
"""
|
||||
Deterministic on the puzzle-driving dimensions (category share, per-category
|
||||
success rate). Random on incidental dimensions (unit, scale, hour, day-within-month).
|
||||
"""
|
||||
random.seed(seed)
|
||||
ops: list[Op] = []
|
||||
|
||||
for month, total in MONTHLY_OPS.items():
|
||||
year = int(month[:4])
|
||||
mo = int(month[5:7])
|
||||
days = days_in_month(year, mo)
|
||||
shares = category_share(month)
|
||||
|
||||
# Exact category counts per month (no sampling noise on the shares)
|
||||
cat_counts = exact_counts(total, shares)
|
||||
|
||||
for cat, n_cat in cat_counts.items():
|
||||
seg = SEGMENT_OF[cat]
|
||||
base = CATEGORY_BASE_SUCCESS[cat]
|
||||
# Exact success count for this (month, category) — no Bernoulli noise
|
||||
n_success = round(n_cat * base)
|
||||
|
||||
# Build the ops list for this bucket, mark first n_success as succeeded
|
||||
bucket = []
|
||||
for i in range(n_cat):
|
||||
d = random.choice(days)
|
||||
wd_idx = (d.weekday() + 1) % 7
|
||||
bucket.append(Op(
|
||||
date=d.isoformat(), month=month,
|
||||
weekday=WEEKDAY_NAMES[wd_idx], weekday_num=wd_idx,
|
||||
hour=weighted_int(HOURLY_WEIGHT),
|
||||
unit=random.choice(UNITS),
|
||||
category=cat, segment=seg,
|
||||
scale=weighted_choice(SCALE_SHARE),
|
||||
succeeded=(i < n_success),
|
||||
))
|
||||
random.shuffle(bucket) # don't keep all successes at front of list
|
||||
ops.extend(bucket)
|
||||
|
||||
return ops
|
||||
|
||||
# --- Aggregation: build the 14 GAME_DATA tables game.js expects ---
|
||||
# Field names follow Divvy convention (rides = count, avg_duration = success rate %)
|
||||
# even though the meaning is repurposed. See top-of-file note.
|
||||
|
||||
def round_pct(x: float) -> float:
|
||||
return round(x * 100, 1)
|
||||
|
||||
def safe_pct(num: float, den: float) -> float:
|
||||
return round_pct(num / den) if den else 0.0
|
||||
|
||||
def aggregate(ops: list[Op]) -> dict:
|
||||
out: dict = {}
|
||||
|
||||
# monthly_totals: [{month, rides, avg_duration, median_duration}]
|
||||
by_month: dict[str, list[Op]] = defaultdict(list)
|
||||
for o in ops:
|
||||
by_month[o.month].append(o)
|
||||
out["monthly_totals"] = [
|
||||
{
|
||||
"month": m,
|
||||
"rides": len(rows),
|
||||
"avg_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)),
|
||||
"median_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)), # same as avg for binomial
|
||||
}
|
||||
for m, rows in sorted(by_month.items())
|
||||
]
|
||||
|
||||
# daily_totals: [{date, rides, avg_duration}]
|
||||
by_date: dict[str, list[Op]] = defaultdict(list)
|
||||
for o in ops:
|
||||
by_date[o.date].append(o)
|
||||
out["daily_totals"] = [
|
||||
{
|
||||
"date": d,
|
||||
"rides": len(rows),
|
||||
"avg_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)),
|
||||
}
|
||||
for d, rows in sorted(by_date.items())
|
||||
]
|
||||
|
||||
# day_of_week: [{month, weekday, weekday_num, rides, avg_duration}] for Oct + Nov 2023
|
||||
out["day_of_week"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
by_wd: dict[tuple[str, int], list[Op]] = defaultdict(list)
|
||||
for o in by_month[month]:
|
||||
by_wd[(o.weekday, o.weekday_num)].append(o)
|
||||
for (wd, wd_num), rows in sorted(by_wd.items(), key=lambda kv: kv[0][1]):
|
||||
out["day_of_week"].append({
|
||||
"month": month, "weekday": wd, "weekday_num": wd_num,
|
||||
"rides": len(rows),
|
||||
"avg_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)),
|
||||
})
|
||||
|
||||
# day_counts: [{month, weekday, weekday_num, day_count}] — for calendar artifact
|
||||
out["day_counts"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
year, mo = int(month[:4]), int(month[5:7])
|
||||
days = days_in_month(year, mo)
|
||||
wd_count: dict[tuple[str, int], int] = defaultdict(int)
|
||||
for d in days:
|
||||
wd_idx = (d.weekday() + 1) % 7
|
||||
wd_count[(WEEKDAY_NAMES[wd_idx], wd_idx)] += 1
|
||||
for (wd, wd_num), cnt in sorted(wd_count.items(), key=lambda kv: kv[0][1]):
|
||||
out["day_counts"].append({
|
||||
"month": month, "weekday": wd, "weekday_num": wd_num, "day_count": cnt,
|
||||
})
|
||||
|
||||
# hourly_totals: [{month, hour, rides}] for Oct + Nov 2023
|
||||
out["hourly_totals"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
for h in range(24):
|
||||
n = sum(1 for o in by_month[month] if o.hour == h)
|
||||
out["hourly_totals"].append({"month": month, "hour": h, "rides": n})
|
||||
|
||||
# hourly_patterns: per-segment hourly (used by game.js but we keep simpler)
|
||||
out["hourly_patterns"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
for seg in ["preparation", "combat"]:
|
||||
for h in range(24):
|
||||
n = sum(1 for o in by_month[month] if o.hour == h and o.segment == seg)
|
||||
out["hourly_patterns"].append({"month": month, "segment": seg, "hour": h, "rides": n})
|
||||
|
||||
# bike_type → mission_category: per-category aggregate Oct + Nov 2023
|
||||
out["bike_type"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
for cat in CATEGORIES:
|
||||
rows = [o for o in by_month[month] if o.category == cat]
|
||||
out["bike_type"].append({
|
||||
"month": month, "bike_type": cat, # field name kept for game.js compat
|
||||
"rides": len(rows),
|
||||
"avg_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)),
|
||||
})
|
||||
|
||||
# duration_buckets → mission_scale: per-scale aggregate Oct + Nov 2023
|
||||
out["duration_buckets"] = []
|
||||
for month in ["2023-10", "2023-11"]:
|
||||
for scale in SCALES:
|
||||
rows = [o for o in by_month[month] if o.scale == scale]
|
||||
out["duration_buckets"].append({
|
||||
"month": month, "bucket": scale, "bucket_order": SCALE_ORDER[scale],
|
||||
"rides": len(rows),
|
||||
})
|
||||
|
||||
# station_comparison → unit_comparison: top units, Oct→Nov change
|
||||
out["station_comparison"] = []
|
||||
for unit in UNITS:
|
||||
oct_rows = [o for o in by_month["2023-10"] if o.unit == unit]
|
||||
nov_rows = [o for o in by_month["2023-11"] if o.unit == unit]
|
||||
oct_n = len(oct_rows)
|
||||
nov_n = len(nov_rows)
|
||||
change = round((nov_n - oct_n) / oct_n * 100, 1) if oct_n else 0.0
|
||||
out["station_comparison"].append({
|
||||
"station": unit, # field name kept
|
||||
"oct_rides": oct_n, "nov_rides": nov_n, "change_pct": change,
|
||||
})
|
||||
out["station_comparison"].sort(key=lambda r: -r["oct_rides"])
|
||||
|
||||
# monthly_by_segment: [{month, segment, rides, avg_duration}] — all months
|
||||
out["monthly_by_segment"] = []
|
||||
for m, rows in sorted(by_month.items()):
|
||||
for seg in ["preparation", "combat"]:
|
||||
sub = [o for o in rows if o.segment == seg]
|
||||
out["monthly_by_segment"].append({
|
||||
"month": m, "segment": seg,
|
||||
"rides": len(sub),
|
||||
"avg_duration": safe_pct(sum(1 for o in sub if o.succeeded), len(sub)),
|
||||
})
|
||||
|
||||
# daily_by_segment: [{date, segment, rides, avg_duration}]
|
||||
out["daily_by_segment"] = []
|
||||
for d, rows in sorted(by_date.items()):
|
||||
for seg in ["preparation", "combat"]:
|
||||
sub = [o for o in rows if o.segment == seg]
|
||||
out["daily_by_segment"].append({
|
||||
"date": d, "segment": seg,
|
||||
"rides": len(sub),
|
||||
"avg_duration": safe_pct(sum(1 for o in sub if o.succeeded), len(sub)),
|
||||
})
|
||||
|
||||
# null_station_rate: data-quality table (game.js doesn't actively use, but referenced)
|
||||
out["null_station_rate"] = [] # leave empty; game.js handles missing gracefully
|
||||
|
||||
# yoy_data + yoy_totals: Nov 2022 vs Nov 2023
|
||||
out["yoy_data"] = []
|
||||
out["yoy_totals"] = []
|
||||
for month in ["2022-11", "2023-11"]:
|
||||
rows = by_month[month]
|
||||
out["yoy_totals"].append({
|
||||
"month": month,
|
||||
"rides": len(rows),
|
||||
"avg_duration": safe_pct(sum(1 for o in rows if o.succeeded), len(rows)),
|
||||
})
|
||||
for seg in ["preparation", "combat"]:
|
||||
sub = [o for o in rows if o.segment == seg]
|
||||
out["yoy_data"].append({
|
||||
"month": month, "segment": seg,
|
||||
"rides": len(sub),
|
||||
"avg_duration": safe_pct(sum(1 for o in sub if o.succeeded), len(sub)),
|
||||
})
|
||||
|
||||
# scenario: VP-message metadata
|
||||
oct_rows = by_month["2023-10"]
|
||||
nov_rows = by_month["2023-11"]
|
||||
oct_ops = len(oct_rows)
|
||||
nov_ops = len(nov_rows)
|
||||
oct_eff = safe_pct(sum(1 for o in oct_rows if o.succeeded), oct_ops)
|
||||
nov_eff = safe_pct(sum(1 for o in nov_rows if o.succeeded), nov_ops)
|
||||
out["scenario"] = {
|
||||
"prior_month": "2023-10",
|
||||
"prior_month_label": "Жовтень 2023",
|
||||
"focus_month": "2023-11",
|
||||
"focus_month_label": "Листопад 2023",
|
||||
"prior_rides": oct_ops, # = ops count for prior month
|
||||
"focus_rides": nov_ops, # = ops count for focus month
|
||||
"rides_change_pct": round((nov_ops - oct_ops) / oct_ops * 100, 1),
|
||||
"prior_avg_duration": oct_eff,
|
||||
"focus_avg_duration": nov_eff,
|
||||
"duration_change_pct": round(nov_eff - oct_eff, 1), # difference of percentages, not relative
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
# --- Output ---
|
||||
|
||||
def write_facts_csv(ops: list[Op], path: Path):
|
||||
with path.open("w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["date", "month", "weekday", "hour", "unit", "category", "segment", "scale", "succeeded"])
|
||||
for o in ops:
|
||||
w.writerow([o.date, o.month, o.weekday, o.hour, o.unit, o.category, o.segment, o.scale, int(o.succeeded)])
|
||||
|
||||
def write_game_data_js(data: dict, path: Path):
|
||||
header = (
|
||||
"// Auto-generated — do not edit manually. Run: python3 generate_data.py\n"
|
||||
"// Synthetic dataset for the L5 BI Detective game (KSE × ХАРТІЯ data-literacy course).\n"
|
||||
"// Field-name mapping (kept compatible with BUS220 game.js):\n"
|
||||
"// rides = number of operations (ops count)\n"
|
||||
"// avg_duration = success rate, percent (0–100)\n"
|
||||
"// bike_type = mission_category (training / logistics / recon / fire_support / defensive)\n"
|
||||
"// segment = preparation (training+logistics) | combat (recon+fire_support+defensive)\n"
|
||||
"// bucket = mission_scale (small/medium/large)\n"
|
||||
"// station = unit name\n"
|
||||
)
|
||||
payload = "const GAME_DATA = " + json.dumps(data, ensure_ascii=False, separators=(",", ":")) + ";\n"
|
||||
path.write_text(header + payload)
|
||||
|
||||
def main():
|
||||
print("Generating synthetic operations…")
|
||||
ops = generate_ops(seed=42)
|
||||
print(f" Total ops generated: {len(ops):,}")
|
||||
|
||||
write_facts_csv(ops, OUT_DIR / "raw_facts.csv")
|
||||
print(f" Wrote {OUT_DIR / 'raw_facts.csv'}")
|
||||
|
||||
print("Aggregating into game tables…")
|
||||
data = aggregate(ops)
|
||||
|
||||
write_game_data_js(data, OUT_DIR / "game_data.js")
|
||||
print(f" Wrote {OUT_DIR / 'game_data.js'}")
|
||||
|
||||
# Sanity check — print headline numbers
|
||||
s = data["scenario"]
|
||||
print()
|
||||
print("Headline check:")
|
||||
print(f" {s['prior_month_label']}: {s['prior_rides']} ops, {s['prior_avg_duration']}% efficiency")
|
||||
print(f" {s['focus_month_label']}: {s['focus_rides']} ops, {s['focus_avg_duration']}% efficiency")
|
||||
print(f" Volume change: {s['rides_change_pct']:+.1f}%")
|
||||
print(f" Efficiency change: {s['duration_change_pct']:+.1f} pp")
|
||||
print()
|
||||
print("Per-category success rate (should be stable across Oct → Nov):")
|
||||
bt = data["bike_type"]
|
||||
for cat in CATEGORIES:
|
||||
oct_v = next((r for r in bt if r["month"] == "2023-10" and r["bike_type"] == cat), None)
|
||||
nov_v = next((r for r in bt if r["month"] == "2023-11" and r["bike_type"] == cat), None)
|
||||
if oct_v and nov_v:
|
||||
print(f" {cat:14s}: Oct {oct_v['avg_duration']:5.1f}% (n={oct_v['rides']:3d}) → Nov {nov_v['avg_duration']:5.1f}% (n={nov_v['rides']:3d})")
|
||||
print()
|
||||
print("Composition (share of total ops):")
|
||||
oct_total = sum(r["rides"] for r in bt if r["month"] == "2023-10")
|
||||
nov_total = sum(r["rides"] for r in bt if r["month"] == "2023-11")
|
||||
for cat in CATEGORIES:
|
||||
oct_v = next((r for r in bt if r["month"] == "2023-10" and r["bike_type"] == cat), None)
|
||||
nov_v = next((r for r in bt if r["month"] == "2023-11" and r["bike_type"] == cat), None)
|
||||
if oct_v and nov_v:
|
||||
print(f" {cat:14s}: Oct {oct_v['rides']/oct_total*100:5.1f}% → Nov {nov_v['rides']/nov_total*100:5.1f}%")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+34
-266
@@ -1,250 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BI Detective — розслідування дашборду</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap');
|
||||
|
||||
/* Quarto overrides for custom page */
|
||||
#quarto-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.page-layout-custom .quarto-container {
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
#quarto-margin-sidebar, #quarto-sidebar { display: none !important; }
|
||||
#title-block-header { display: none; }
|
||||
|
||||
/* Game variables */
|
||||
.game-root {
|
||||
--primary: #003964;
|
||||
--blue: #00BBCE;
|
||||
--green: #A7C539;
|
||||
--red: #F15B43;
|
||||
--font: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--left-w: 360px;
|
||||
font-family: var(--font);
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.game-root .header { background: var(--primary); color: white; padding: 8px 20px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.game-root .header h1 { font-size: 15px; font-weight: 600; margin: 0; }
|
||||
.game-root .header-right { display: flex; align-items: center; gap: 14px; }
|
||||
.game-root .views-tag { font-size: 12px; opacity: 0.75; }
|
||||
.game-root .restart-btn { background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3); padding: 4px 10px; border-radius: 5px; cursor: pointer; font-size: 11px; font-family: var(--font); }
|
||||
.game-root .restart-btn:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* App layout */
|
||||
.game-root .app { flex: 1; display: grid; grid-template-columns: var(--left-w) 1fr; overflow: hidden; }
|
||||
|
||||
/* Left panel */
|
||||
.game-root .left-panel { display: flex; flex-direction: column; border-right: 1px solid #ddd; background: #fff; overflow: hidden; }
|
||||
|
||||
.game-root .vp-section { padding: 14px 16px; border-bottom: 1px solid #eee; flex-shrink: 0; }
|
||||
.game-root .vp-msg { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||
.game-root .vp-avatar { width: 32px; height: 32px; border-radius: 6px; background: var(--primary); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: white; flex-shrink: 0; }
|
||||
.game-root .vp-content { flex: 1; min-width: 0; }
|
||||
.game-root .vp-name { font-size: 12px; font-weight: 600; color: var(--primary); }
|
||||
.game-root .vp-title { font-size: 11px; color: #999; }
|
||||
.game-root .vp-text { font-size: 13px; line-height: 1.45; color: #333; margin-top: 3px; }
|
||||
|
||||
.game-root .alert-metrics { display: flex; gap: 8px; }
|
||||
.game-root .alert-metric { flex: 1; background: #fef2f0; border: 1px solid #fcd5cf; border-radius: 6px; padding: 8px 10px; }
|
||||
.game-root .alert-metric .am-label { font-size: 10px; text-transform: uppercase; color: #888; letter-spacing: 0.3px; }
|
||||
.game-root .alert-metric .am-val { font-size: 18px; font-weight: 700; color: var(--primary); }
|
||||
.game-root .alert-metric .am-delta { font-size: 12px; font-weight: 600; color: var(--red); }
|
||||
|
||||
/* Insights */
|
||||
.game-root .insights-section { flex: 1; overflow-y: auto; padding: 12px 16px; }
|
||||
.game-root .insights-heading { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; margin-bottom: 8px; font-weight: 600; }
|
||||
|
||||
.game-root .insight-card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; cursor: default; transition: all 0.3s ease; position: relative; }
|
||||
.game-root .insight-card.locked { background: #f8f8f8; border-color: #eee; }
|
||||
.game-root .insight-placeholder { font-size: 12px; color: #bbb; font-style: italic; }
|
||||
.game-root .insight-card.locked .insight-check { display: none; }
|
||||
|
||||
.game-root .insight-card.unlocked { opacity: 1; border-color: var(--blue); background: #f0fbfd; animation: unlockPulse 0.6s ease; }
|
||||
.game-root .insight-card.unlocked.conclusion { border-color: var(--green); background: #f4f9e8; }
|
||||
@keyframes unlockPulse { 0% { box-shadow: 0 0 0 0 rgba(0,187,206,0.4); } 70% { box-shadow: 0 0 0 8px rgba(0,187,206,0); } 100% { box-shadow: none; } }
|
||||
|
||||
.game-root .insight-text { font-size: 13px; line-height: 1.4; }
|
||||
.game-root .insight-tag { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-top: 4px; }
|
||||
.game-root .insight-tag.observation { background: #e3f2fd; color: #1565c0; }
|
||||
.game-root .insight-tag.conclusion { background: #e8f5e9; color: #2e7d32; }
|
||||
|
||||
.game-root .insight-check { position: absolute; right: 10px; top: 10px; width: 20px; height: 20px; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 13px; color: white; background: white; transition: all 0.2s; }
|
||||
.game-root .insight-card.unlocked.conclusion .insight-check { display: flex; border-color: var(--green); }
|
||||
.game-root .insight-card.unlocked:not(.conclusion) .insight-check { display: none; }
|
||||
.game-root .insight-check.checked { background: var(--green); border-color: var(--green); }
|
||||
|
||||
/* Connection card */
|
||||
.game-root .connection-card { background: #fff8e1; border-left: 3px solid #ffc107; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; font-size: 12px; line-height: 1.5; color: #555; animation: unlockPulse 0.6s ease; }
|
||||
.game-root .connection-card strong { color: var(--primary); }
|
||||
|
||||
/* Submit */
|
||||
.game-root .submit-section { padding: 12px 16px; border-top: 1px solid #eee; flex-shrink: 0; }
|
||||
.game-root .submit-btn { width: 100%; padding: 10px; border: none; border-radius: 7px; font-size: 14px; font-weight: 600; font-family: var(--font); cursor: pointer; transition: all 0.2s; }
|
||||
.game-root .submit-btn:disabled { background: #e0e0e0; color: #999; cursor: not-allowed; }
|
||||
.game-root .submit-btn:not(:disabled) { background: var(--green); color: var(--primary); }
|
||||
.game-root .submit-btn:not(:disabled):hover { background: #96b432; }
|
||||
.game-root .submit-hint { font-size: 11px; color: #aaa; text-align: center; margin-top: 4px; }
|
||||
|
||||
/* Right panel */
|
||||
.game-root .right-panel { display: flex; flex-direction: column; overflow: hidden; background: #f4f4f8; }
|
||||
|
||||
.game-root .tab-bar { display: flex; border-bottom: 2px solid #e0e0e0; padding: 0 16px; flex-shrink: 0; background: white; }
|
||||
.game-root .tab-btn { padding: 10px 18px; font-size: 13px; font-family: var(--font); font-weight: 600; border: none; background: none; cursor: pointer; color: #888; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s; }
|
||||
.game-root .tab-btn:hover { color: var(--primary); }
|
||||
.game-root .tab-btn.active { color: var(--primary); border-bottom-color: var(--blue); }
|
||||
|
||||
.game-root .tab-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.game-root .tab-pane { display: none; }
|
||||
.game-root .tab-pane.active { display: block; }
|
||||
|
||||
.game-root .chart-card { background: white; border: 1px solid #e0e0e0; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
||||
.game-root .chart-card h3 { font-size: 14px; color: var(--primary); margin-bottom: 10px; }
|
||||
.game-root .chart-card canvas { max-height: 260px; }
|
||||
|
||||
.game-root .toggle-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.game-root .toggle-label { font-size: 12px; color: #888; }
|
||||
.game-root .toggle-btn { padding: 6px 14px; border-radius: 20px; font-size: 12px; font-family: var(--font); cursor: pointer; border: 1px solid #ccc; background: white; color: #666; transition: all 0.15s; }
|
||||
.game-root .toggle-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
|
||||
/* Sub-tabs */
|
||||
.game-root .sub-tab-bar { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.game-root .sub-tab-btn { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-family: var(--font); cursor: pointer; border: 1px solid #ddd; background: white; color: #666; transition: all 0.15s; }
|
||||
.game-root .sub-tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
|
||||
/* Metrics row */
|
||||
.game-root .metrics-row { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.game-root .metric-card { flex: 1; min-width: 120px; background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 14px; }
|
||||
.game-root .metric-card .mc-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.game-root .metric-card .mc-val { font-size: 20px; font-weight: 700; color: var(--primary); margin: 2px 0; }
|
||||
.game-root .metric-card .mc-delta { font-size: 12px; font-weight: 600; }
|
||||
.game-root .metric-card .mc-delta.pos { color: #2e7d32; }
|
||||
.game-root .metric-card .mc-delta.neg { color: var(--red); }
|
||||
|
||||
/* Quiz system */
|
||||
.game-root .quiz-section { background: white; border: 1px solid #e0e0e0; border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
|
||||
.game-root .quiz-prompt { font-size: 13px; font-weight: 600; color: var(--primary); margin-bottom: 10px; }
|
||||
.game-root .quiz-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.game-root .quiz-opt { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; background: white; cursor: pointer; font-family: var(--font); font-size: 13px; line-height: 1.4; text-align: left; color: #333; transition: all 0.2s; }
|
||||
.game-root .quiz-opt:hover:not(.disabled) { border-color: var(--blue); background: #f8fcfd; }
|
||||
.game-root .quiz-opt .q-icon { flex-shrink: 0; width: 20px; height: 20px; border: 2px solid #ccc; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; margin-top: 1px; transition: all 0.2s; }
|
||||
.game-root .quiz-opt.correct { border-color: var(--green); background: #f4f9e8; cursor: default; }
|
||||
.game-root .quiz-opt.correct .q-icon { border-color: var(--green); background: var(--green); color: white; }
|
||||
.game-root .quiz-opt.wrong { border-color: var(--red); background: #fef2f0; }
|
||||
.game-root .quiz-opt.wrong .q-icon { border-color: var(--red); background: var(--red); color: white; }
|
||||
.game-root .quiz-opt.disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
||||
.game-root .quiz-opt.faded { opacity: 0.4; cursor: default; pointer-events: none; }
|
||||
.game-root .quiz-feedback { margin-top: 8px; font-size: 12px; line-height: 1.5; color: #555; padding: 8px 10px; border-radius: 6px; animation: fadeIn 0.2s ease; display: none; }
|
||||
.game-root .quiz-feedback.visible { display: block; }
|
||||
.game-root .quiz-feedback.wrong-fb { background: #fef2f0; border-left: 3px solid var(--red); }
|
||||
.game-root .quiz-feedback.correct-fb { background: #f4f9e8; border-left: 3px solid var(--green); }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Lesson cards */
|
||||
.game-root .lesson-card { background: #fff8e1; border-left: 3px solid #ffb300; border-radius: 6px; padding: 10px 14px; margin-top: 12px; }
|
||||
.game-root .lesson-card h4 { font-size: 12px; color: #e65100; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.game-root .lesson-card p { font-size: 13px; line-height: 1.5; color: #555; }
|
||||
.game-root .lesson-hidden { display: none; }
|
||||
|
||||
/* Intro overlay */
|
||||
.intro-overlay { position: fixed; inset: 0; background: rgba(0,57,100,0.85); display: flex; z-index: 1001; align-items: center; justify-content: center; }
|
||||
.intro-overlay.hidden { display: none; }
|
||||
.intro-box { background: white; border-radius: 14px; max-width: 560px; width: 90%; padding: 32px 36px; box-shadow: 0 8px 40px rgba(0,0,0,0.3); }
|
||||
.intro-box h2 { color: #003964; margin: 0 0 6px; font-size: 22px; }
|
||||
.intro-box .intro-sub { color: #888; font-size: 13px; margin-bottom: 18px; }
|
||||
.intro-box .intro-role { background: #f0fbfd; border-left: 3px solid #00BBCE; border-radius: 6px; padding: 12px 14px; margin-bottom: 16px; font-size: 14px; line-height: 1.55; color: #333; }
|
||||
.intro-box .intro-data { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 13px; margin-bottom: 18px; }
|
||||
.intro-box .intro-data dt { font-weight: 600; color: #888; text-transform: uppercase; font-size: 11px; letter-spacing: 0.3px; padding-top: 2px; }
|
||||
.intro-box .intro-data dd { margin: 0; color: #333; }
|
||||
.intro-box .intro-task { font-size: 13px; color: #555; line-height: 1.5; margin-bottom: 20px; }
|
||||
.intro-box .intro-start { display: block; width: 100%; padding: 12px; border: none; border-radius: 7px; font-size: 15px; font-weight: 600; font-family: 'Source Sans Pro', sans-serif; cursor: pointer; background: #003964; color: white; }
|
||||
.intro-box .intro-start:hover { background: #004a7c; }
|
||||
|
||||
/* Summary overlay */
|
||||
.summary-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; z-index: 1000; align-items: center; justify-content: center; }
|
||||
.summary-overlay.visible { display: flex; }
|
||||
.summary-box { background: white; border-radius: 14px; max-width: 720px; width: 90%; max-height: 90vh; overflow-y: auto; padding: 30px; box-shadow: 0 8px 40px rgba(0,0,0,0.2); animation: slideUp 0.3s ease; }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; } }
|
||||
|
||||
.summary-box h2 { color: var(--primary); margin-bottom: 6px; }
|
||||
.summary-score { text-align: center; margin: 16px 0 24px; }
|
||||
.summary-score .big { font-size: 42px; font-weight: 700; color: var(--primary); display: block; }
|
||||
.summary-score .sub { font-size: 14px; color: #888; }
|
||||
.s-card { background: #f8f9fa; border-radius: 10px; padding: 16px 18px; margin-bottom: 14px; }
|
||||
.s-card h3 { font-size: 15px; color: var(--primary); margin-bottom: 6px; }
|
||||
.s-card p { font-size: 14px; line-height: 1.55; color: #444; margin-bottom: 6px; }
|
||||
.s-card p:last-child { margin-bottom: 0; }
|
||||
.s-card.connection { background: #fff8e1; border-left: 3px solid #ffc107; }
|
||||
.s-card.bridge { background: #e8f5e9; border-left: 3px solid var(--green); }
|
||||
.s-card.reframe { background: #e3f2fd; border-left: 3px solid var(--blue); }
|
||||
.s-card.discussion { background: #f3f3f3; }
|
||||
.s-card.discussion p { margin-bottom: 4px; }
|
||||
.s-card .s-metrics { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.s-card .s-metric { background: white; border: 1px solid #e0e0e0; border-radius: 6px; padding: 8px 12px; flex: 1; min-width: 100px; text-align: center; }
|
||||
.s-card .s-metric .sm-label { font-size: 10px; color: #888; text-transform: uppercase; }
|
||||
.s-card .s-metric .sm-val { font-size: 18px; font-weight: 700; color: var(--primary); }
|
||||
.s-card .s-metric .sm-delta { font-size: 12px; font-weight: 600; }
|
||||
|
||||
.summary-actions { text-align: center; margin-top: 20px; }
|
||||
.summary-actions button { padding: 10px 28px; border: none; border-radius: 7px; font-size: 14px; font-weight: 600; font-family: 'Source Sans Pro', sans-serif; cursor: pointer; background: var(--primary); color: white; }
|
||||
.summary-actions button:hover { background: #004a7c; }
|
||||
|
||||
.bonus-list { list-style: none; padding: 0; }
|
||||
.bonus-list li { font-size: 13px; padding: 2px 0; color: #555; }
|
||||
.bonus-list li::before { content: '\2713 '; color: var(--green); font-weight: 700; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.game-root .app { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
|
||||
.game-root .right-panel { order: -1; }
|
||||
.game-root .left-panel { max-height: 40vh; }
|
||||
.game-root { --left-w: 100%; }
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BI-детектив: Розслідування дашборду</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-root">
|
||||
|
||||
<div class="header">
|
||||
<h1>BI Detective — розслідування дашборду</h1>
|
||||
<h1>BI-детектив: Розслідування дашборду</h1>
|
||||
<div class="header-right">
|
||||
<span class="views-tag" id="viewsTag"></span>
|
||||
<button class="restart-btn" onclick="restart()">Заново</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="intro-overlay" id="introOverlay">
|
||||
<div class="intro-box">
|
||||
<h2>BI Detective</h2>
|
||||
<div class="intro-sub">Інтерактивне розслідування</div>
|
||||
<div class="intro-role">
|
||||
Ви — аналітик у штабі бригади. Командир щойно написав вам про дивні листопадові цифри. Ваша задача: пройти вкладки дашборду, відповісти на питання у кожній, і розібратися, що насправді стоїть за показниками — до брифінгу.
|
||||
</div>
|
||||
<dl class="intro-data">
|
||||
<dt>Дані</dt>
|
||||
<dd>Синтетичний (імітований) набір операцій бригади — для навчальних цілей</dd>
|
||||
<dt>Період</dt>
|
||||
<dd>Листопад 2022 – Листопад 2023 (13 місяців)</dd>
|
||||
<dt>Масштаб</dt>
|
||||
<dd>~8 500 операцій</dd>
|
||||
<dt>Тип операцій</dt>
|
||||
<dd><strong>Підготовчі</strong> — тренування, логістика · <strong>Бойові</strong> — розвідка, вогневе ураження, оборонні дії</dd>
|
||||
</dl>
|
||||
<div class="intro-task">
|
||||
Йдіть вкладками зліва направо. У кожній — питання. Правильні відповіді відкривають спостереження зліва. Коли назбираєте докази — надішліть аналіз командирові.
|
||||
</div>
|
||||
<button class="intro-start" onclick="document.getElementById('introOverlay').classList.add('hidden')">Почати</button>
|
||||
<button class="restart-btn" onclick="restart()">Спочатку</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,21 +24,24 @@
|
||||
<div class="left-panel">
|
||||
<div class="vp-section">
|
||||
<div class="vp-msg">
|
||||
<div class="vp-avatar">МК</div>
|
||||
<div class="vp-avatar">КЛ</div>
|
||||
<div class="vp-content">
|
||||
<div class="vp-name">Михайло Коваль</div>
|
||||
<div class="vp-title">Начальник штабу бригади</div>
|
||||
<div class="vp-text">Ефективність у листопаді — 71%. У жовтні було 62%. Можеш до брифінгу пояснити, що ми такого зробили?</div>
|
||||
<div class="vp-name">Командир логістики</div>
|
||||
<div class="vp-title">Брифінг — завтра вранці</div>
|
||||
<div class="vp-text">За жовтень → листопад рейсів менше на ~30%. До завтрашнього брифінгу: що сталося?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="disclaimer-box">
|
||||
<strong>Зверни увагу.</strong> Дані синтетичні й абстрактні. Завдання — шукати закономірності у самих даних, а не виводити їх із реального світу. Тут працюють лише докази на основі цифр.
|
||||
</div>
|
||||
<div class="alert-metrics">
|
||||
<div class="alert-metric">
|
||||
<div class="am-label">Кількість операцій (листопад)</div>
|
||||
<div class="am-label">Усього рейсів (лист.)</div>
|
||||
<div class="am-val" id="amRides"></div>
|
||||
<div class="am-delta" id="amRidesDelta"></div>
|
||||
</div>
|
||||
<div class="alert-metric">
|
||||
<div class="am-label">Ефективність (листопад)</div>
|
||||
<div class="am-label">Сер. час обороту (лист.)</div>
|
||||
<div class="am-val" id="amDur"></div>
|
||||
<div class="am-delta" id="amDurDelta"></div>
|
||||
</div>
|
||||
@@ -284,57 +57,57 @@
|
||||
</div>
|
||||
|
||||
<div class="submit-section">
|
||||
<button class="submit-btn" id="submitBtn" disabled onclick="submitAnswer()">Надіслати аналіз командирові</button>
|
||||
<div class="submit-hint" id="submitHint">Пройдіть вкладки і відповідайте на питання, щоб зібрати докази</div>
|
||||
<button class="submit-btn" id="submitBtn" disabled onclick="submitAnswer()">Надіслати аналіз командиру</button>
|
||||
<div class="submit-hint" id="submitHint">Дослідь дашборд і відповідай на питання, щоб зібрати докази</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT PANEL: dashboard tabs -->
|
||||
<div class="right-panel">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="overview" onclick="switchTab('overview')">Загальний огляд</button>
|
||||
<button class="tab-btn active" data-tab="overview" onclick="switchTab('overview')">Огляд</button>
|
||||
<button class="tab-btn" data-tab="breakdowns" onclick="switchTab('breakdowns')">Розрізи</button>
|
||||
<button class="tab-btn" data-tab="riders" onclick="switchTab('riders')">Тип операцій</button>
|
||||
<button class="tab-btn" data-tab="duration" onclick="switchTab('duration')">Ефективність</button>
|
||||
<button class="tab-btn" data-tab="riders" onclick="switchTab('riders')">Замовники</button>
|
||||
<button class="tab-btn" data-tab="duration" onclick="switchTab('duration')">Час обороту</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
|
||||
<div class="tab-pane active" id="pane-overview">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">Деталізація:</span>
|
||||
<span class="toggle-label">Гранулярність:</span>
|
||||
<button class="toggle-btn active" onclick="toggleOverview('monthly')">Місячна</button>
|
||||
<button class="toggle-btn" onclick="toggleOverview('daily')">Денна</button>
|
||||
</div>
|
||||
<div class="chart-card"><h3>Кількість операцій</h3><canvas id="chartOverviewRides"></canvas></div>
|
||||
<div class="chart-card"><h3>Ефективність (% успіху)</h3><canvas id="chartOverviewDuration"></canvas></div>
|
||||
<div class="chart-card"><h3>Усього рейсів</h3><canvas id="chartOverviewRides"></canvas></div>
|
||||
<div class="chart-card"><h3>Середній час обороту (хв)</h3><canvas id="chartOverviewDuration"></canvas></div>
|
||||
<div id="quiz-overview-spot"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" id="pane-breakdowns">
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab-btn active" data-subtab="dayofweek" onclick="switchSubTab('dayofweek')">День тижня</button>
|
||||
<button class="sub-tab-btn" data-subtab="hourly" onclick="switchSubTab('hourly')">Час доби</button>
|
||||
<button class="sub-tab-btn" data-subtab="biketypes" onclick="switchSubTab('biketypes')">Категорія</button>
|
||||
<button class="sub-tab-btn" data-subtab="duration_buckets" onclick="switchSubTab('duration_buckets')">Масштаб</button>
|
||||
<button class="sub-tab-btn" data-subtab="stations" onclick="switchSubTab('stations')">Підрозділ</button>
|
||||
<button class="sub-tab-btn" data-subtab="hourly" onclick="switchSubTab('hourly')">Погодинно</button>
|
||||
<button class="sub-tab-btn" data-subtab="vehicletypes" onclick="switchSubTab('vehicletypes')">Клас техніки</button>
|
||||
<button class="sub-tab-btn" data-subtab="duration_buckets" onclick="switchSubTab('duration_buckets')">Тривалість</button>
|
||||
<button class="sub-tab-btn" data-subtab="stations" onclick="switchSubTab('stations')">Вузли</button>
|
||||
</div>
|
||||
<div id="breakdowns-content"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" id="pane-riders">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">Деталізація:</span>
|
||||
<span class="toggle-label">Гранулярність:</span>
|
||||
<button class="toggle-btn active" onclick="toggleRiders('monthly')">Місячна</button>
|
||||
<button class="toggle-btn" onclick="toggleRiders('daily')">Денна</button>
|
||||
</div>
|
||||
<div class="chart-card"><h3>Операції за типом (підготовчі / бойові)</h3><canvas id="chartRiderRides"></canvas></div>
|
||||
<div class="chart-card"><h3>Частка підготовчих у загальній кількості</h3><canvas id="chartRiderMix"></canvas></div>
|
||||
<div class="chart-card"><h3>Рейси за типом замовника</h3><canvas id="chartRiderRides"></canvas></div>
|
||||
<div class="chart-card"><h3>Частка передових від усіх рейсів</h3><canvas id="chartRiderMix"></canvas></div>
|
||||
<div id="quiz-riders-spot"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" id="pane-duration">
|
||||
<div class="chart-card"><h3>Ефективність за типом (жовтень → листопад)</h3><canvas id="chartDurChange"></canvas></div>
|
||||
<div class="chart-card"><h3>Ефективність за типом — динаміка</h3><canvas id="chartDurTime"></canvas></div>
|
||||
<div class="chart-card"><h3>Сер. час обороту за групою (Жов → Лис)</h3><canvas id="chartDurChange"></canvas></div>
|
||||
<div class="chart-card"><h3>Сер. час обороту в часі за групою</h3><canvas id="chartDurTime"></canvas></div>
|
||||
<div id="quiz-duration-spot"></div>
|
||||
</div>
|
||||
|
||||
@@ -347,13 +120,8 @@
|
||||
<div class="summary-box" id="summaryBox"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
<script src="game_data.js"></script>
|
||||
<script src="quizzes.js"></script>
|
||||
<script src="game.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+79
-90
@@ -1,143 +1,132 @@
|
||||
// Quiz questions, observation/conclusion definitions, and dead-end lesson labels.
|
||||
// Ported from BUS220 BI Detective (Divvy bike-share) to ХАРТІЯ brigade-efficiency scenario.
|
||||
// Internal keys (yoyPositive, casualDropMore, etc.) preserved for game.js compatibility.
|
||||
// Питання вікторини, описи інсайтів і підсумкові тексти.
|
||||
// Це файл для редагування контенту гри.
|
||||
|
||||
const QUIZZES = {
|
||||
overview: {
|
||||
prompt: 'Поглянь на повний таймлайн ефективності та обсягу. Зростання з 62% до 71% — це дійсно покращення?',
|
||||
prompt: 'Подивись на повний таймлайн. Чи незвичним є падіння жовтень → листопад?',
|
||||
insightKey: 'yoyPositive',
|
||||
options: [
|
||||
{ text: 'Подивись на минулорічний листопад — ефективність тоді була ~62%, як і у жовтні цього року. Цьогоріч уперше з\'явився такий стрибок. Стрибок справжній — питання, що його викликало.', correct: true,
|
||||
feedback: 'Правильно. Минулого листопада ефективність була типова (~62%). Ця листопадова цифра 71% — справді нове явище. Тепер потрібно з\'ясувати, *чому*. Завжди питай: *"порівняно з чим?"*' },
|
||||
{ text: 'Так — це чисте покращення в роботі. Підрозділи стали ефективнішими.', correct: false,
|
||||
feedback: 'Не поспішай. *"Стало ефективніше"* — це остання з можливих відповідей, яку треба перевіряти, не перша. Спочатку виключи: сезонність, зміну композиції, шум, інший знаменник. Тут є дані за минулий рік — використай їх.' },
|
||||
{ text: 'Ні, скоріше за все це сезонна аномалія — кожного листопада ефективність зростає.', correct: false,
|
||||
feedback: 'Подивись на минулий листопад — там ефективність була ~62%, не 71%. Цей листопад — *перший*, де так високо. Сезонність не пояснює.' },
|
||||
{ text: 'Ні — торік у листопаді було подібне падіння. Це повторюваний сезонний патерн, і листопад 2023 насправді має більше рейсів, ніж листопад 2022.', correct: true,
|
||||
feedback: 'Правильно. Листопад 2023 має на +7,6% більше рейсів, ніж листопад 2022. Падіння жовтень → листопад трапляється щороку, коли холоднішає. Завжди питай: «Порівняно з чим?»' },
|
||||
{ text: 'Так — цьогорічне падіння різкіше, ніж торішнє, щось не так', correct: false,
|
||||
feedback: 'Порівняй цифри листопада: листопад 2022 мав ~330 тис. рейсів, листопад 2023 має ~355 тис. рейсів. Це зростання, а не спад. Падіння жовтень → листопад є сезонним і повторюється щороку.' },
|
||||
{ text: 'Важко сказати — потрібно більше років даних, щоб бути певним', correct: false,
|
||||
feedback: 'Більше років допомогло б, але навіть один попередній листопад показує той самий патерн. І листопад 2023 насправді має більше рейсів, ніж листопад 2022 — діяльність зростає рік до року.' },
|
||||
]
|
||||
},
|
||||
|
||||
dayofweek: {
|
||||
prompt: 'Чому в листопаді операцій менше? Може, за днями тижня щось видно?',
|
||||
prompt: 'Четвер просів зовсім трохи (-1%). Чи дає цей день якусь підказку?',
|
||||
insightKey: null, lessonKey: 'dayofweek',
|
||||
options: [
|
||||
{ text: 'Ні — обидва місяці мають різну кількість буднів і вихідних. У листопаді 2023 на один четвер більше, ніж у жовтні; це просто календарний артефакт.', correct: true,
|
||||
feedback: 'Правильно! Це календарний артефакт. Жовтень 2023 мав інший набір днів тижня, ніж листопад. Якщо нормалізувати на кількість днів кожного типу — падіння плюс-мінус рівномірне. Завжди перевіряй: чи однакова структура в порівнюваних періодах.' },
|
||||
{ text: 'Так — у середу/четвер виконують більше операцій, а в листопаді цих днів виявилось менше', correct: false,
|
||||
feedback: 'Поглянь на дні: у листопаді одних днів тижня більше, інших менше — порівняно з жовтнем. Це календарна аномалія, не змістовний патерн.' },
|
||||
{ text: 'Це нічого не пояснює — потрібно дивитись інші розрізи.', correct: false,
|
||||
feedback: 'Частково так, але календарну структуру варто завжди перевіряти першою — якраз щоб не списати на змістовний фактор те, що пояснюється календарем.' },
|
||||
{ text: 'Ні — у жовтні було 4 четверги, а в листопаді 5. Сирі суми вводять в оману, бо в місяцях різна кількість днів кожного дня тижня.', correct: true,
|
||||
feedback: 'Правильно! Це календарний артефакт. Жовтень 2023 мав 4 четверги, листопад 2023 — 5. Якщо нормалізувати на день, кожен день тижня просів на 20-40%. Завжди перевіряй, чи мають твої періоди порівняння однакову структуру.' },
|
||||
{ text: 'Так — четвер тримається стійко, можливо, в четвер відбувається щось особливе', correct: false,
|
||||
feedback: 'Полічи дні: жовтень 2023 мав 4 четверги, листопад 2023 — 5. Більше днів = більше рейсів, тому четвер виглядає стабільним. Якщо рахувати на один четвер, рейсів насправді стало менше на ~30%, як і в інші дні.' },
|
||||
{ text: 'Розкид між днями тижня свідчить, що день тижня — важливий фактор спаду', correct: false,
|
||||
feedback: 'Розкид — переважно календарний артефакт: різні місяці мають різну кількість кожного дня тижня. Якщо нормалізувати (рейси на день), падіння більш-менш рівномірне — 20-40%.' },
|
||||
]
|
||||
},
|
||||
|
||||
hourly: {
|
||||
prompt: 'А за часом доби — що змінилось між жовтнем і листопадом?',
|
||||
prompt: 'Чи показує погодинний розподіл, що саме змінилося між жовтнем і листопадом?',
|
||||
insightKey: null, lessonKey: 'hourly',
|
||||
options: [
|
||||
{ text: 'Нічого — форма крива та сама, просто рівень нижчий. Денний пік на тих самих годинах, нічна тиша так само. Це зміна обсягу, не патерну.', correct: true,
|
||||
feedback: 'Правильно. Коли дві криві ідентичні за формою і відрізняються лише висотою — час доби нічого не пояснює. Це чиста зміна обсягу, не зміна поведінки. Шукай розрізи, де *форма* змінюється.' },
|
||||
{ text: 'Активність вечорами впала — мабуть, через темряву раніше', correct: false,
|
||||
feedback: 'Подивись уважніше: листопадова крива нижча по всіх годинах, не лише ввечері. Денні піки та нічні мінімуми — ті самі. Темрява не пояснює рівномірне падіння.' },
|
||||
{ text: 'Ранкові операції зросли — нові тактики', correct: false,
|
||||
feedback: 'Ранкові години в листопаді нижчі за жовтневі в абсолюті, як і всі інші. Форма та сама, ніщо не зросло.' },
|
||||
{ text: 'Ні — та сама форма кривої, лише нижча. Денний патерн не змінився; впав тільки обсяг.', correct: true,
|
||||
feedback: 'Правильно. Коли дві криві мають однакову форму, час доби не пояснює зміну. Це зсув обсягу, а не зсув поведінки. Шукай виміри, де змінюється сама форма.' },
|
||||
{ text: 'Вечірні рейси обвалилися — менше світлового дня може виганяти людей із потоку', correct: false,
|
||||
feedback: 'Листопадова крива нижча всюди, не тільки ввечері. Обидві криві мають однакову форму — піки, западини, усе однакове. Жодного специфічного вечірнього обвалу немає.' },
|
||||
{ text: 'Просіли пікові години — можливо, більше людей працює дистанційно', correct: false,
|
||||
feedback: 'Ранкові й вечірні пікові години чітко видно і в листопаді, просто нижчими. Форма ідентична жовтню — патерн виїздів не змінився.' },
|
||||
]
|
||||
},
|
||||
|
||||
biketypes: {
|
||||
prompt: 'Розріз за категоріями операцій. Що тут видно?',
|
||||
insightKey: null, lessonKey: 'biketypes',
|
||||
vehicletypes: {
|
||||
prompt: 'Чи пояснює розбивка за класом техніки падіння рейсів?',
|
||||
insightKey: null, lessonKey: 'vehicletypes',
|
||||
options: [
|
||||
{ text: 'Кількість операцій у кожній категорії впала — окрім підготовчих (тренування, логістика), які навпаки виросли. Категорії змінились по-різному.', correct: true,
|
||||
feedback: 'Правильно. Це і є ключ — категорії рухаються в різні боки. Тренування і логістика — *виросли* в обсязі. Розвідка, вогневе ураження, оборонні дії — *впали*. Це не просто зменшення активності — це структурна зміна. Іди далі: подивись частки.' },
|
||||
{ text: 'Усі категорії впали приблизно однаково — це не пояснює зміну ефективності', correct: false,
|
||||
feedback: 'Подивись точніше — тренування і логістика *зросли* в кількості, не впали. Це важлива асиметрія.' },
|
||||
{ text: 'Оборонні дії впали найсильніше, але це звичайна сезонність', correct: false,
|
||||
feedback: 'Падіння оборонних — справжнє і дуже сильне (-66%). Але «звичайна сезонність» — це твоя інтерпретація без перевірки. Спитай: яка частка кожної категорії була до і стала після?' },
|
||||
{ text: 'Ні — обидва класи техніки впали разом. Цей вимір не пояснює варіацію.', correct: true,
|
||||
feedback: 'Правильно. Коли всі значення у вимірі рухаються в одному напрямку приблизно з однаковою силою, цей вимір не пояснює зміну. Шукай виміри, де значення розходяться.' },
|
||||
{ text: 'Легка техніка тримається краще — проблема саме у важкій', correct: false,
|
||||
feedback: 'Обидва класи просіли подібно (-30% до -34%). Невелика різниця не пояснює загального обвалу.' },
|
||||
]
|
||||
},
|
||||
|
||||
duration_buckets: {
|
||||
prompt: 'Розріз за масштабом операцій (мала / середня / велика). Чи пояснює це різницю?',
|
||||
prompt: 'Найдовші рейси (60+ хв) обвалилися на -58%, а короткі — лише на -23%. Що це підказує?',
|
||||
insightKey: 'longRidesCollapse',
|
||||
options: [
|
||||
{ text: 'Ні — обсяг по всіх масштабах впав плюс-мінус рівномірно. Масштаб не визначає ефективність на дашборді — рухаються всі бакети разом.', correct: true,
|
||||
feedback: 'Правильно. Однорідне падіння по всіх масштабах — означає, що масштаб *не* пояснює зміни. Мала, середня, велика — усі впали приблизно однаково. Шукай розрізи, де частки рухаються по-різному.' },
|
||||
{ text: 'Великі операції впали найбільше — отже, вони пояснюють зміну', correct: false,
|
||||
feedback: 'Подивись числа: всі три бакети впали приблизно на 30-40%. Жодна група не виокремлюється. Якщо однорідно — нічого не пояснюється.' },
|
||||
{ text: 'Малі операції зросли — у листопаді більше дрібних завдань', correct: false,
|
||||
feedback: 'Ні: у листопаді *усіх* масштабів менше, в тому числі малих. Падіння рівномірне.' },
|
||||
{ text: 'Замовники, які беруть довгі рейси, перестали їх замовляти. Якщо вони ще й роблять більше рейсів, ця зміна композиції може пояснити загальне падіння середнього часу обороту.', correct: true,
|
||||
feedback: 'Правильно. Довгі рейси зникають непропорційно. Це підказка, що зі складу вибуває певна група замовників — ті, що замовляють надовго. Це і є механізм падіння середньої тривалості. Подивись на вкладку «Замовники», щоб побачити, хто це.' },
|
||||
{ text: 'Холодна погода змушує всіх обертатися швидше, тому довгі рейси скорочуються найбільше', correct: false,
|
||||
feedback: 'Якби холод укорочував усі рейси однаково, падіння було б рівномірним по всіх кошиках. Але короткі рейси майже не просіли (-23%), а довгі обвалилися (-58%). Працює щось інше, не тільки погода.' },
|
||||
{ text: 'Це просто шум — у довгих рейсах більша варіація, тому вони сильніше коливаються', correct: false,
|
||||
feedback: 'У кошику 60+ хв понад 10 000 рейсів — це не шум. Падіння на -58% — сильний сигнал. Градієнт від -23% до -58% по кошиках розповідає чітку історію: популяція тих, хто бере довгі рейси, скоротилася.' },
|
||||
]
|
||||
},
|
||||
|
||||
stations: {
|
||||
prompt: 'А за підрозділами? Чи виокремлюється якийсь один?',
|
||||
prompt: 'Чи можна вказати конкретні логістичні вузли, через які впала загальна цифра?',
|
||||
insightKey: null, lessonKey: 'stations',
|
||||
options: [
|
||||
{ text: 'Ні — у всіх підрозділів обсяг операцій впав на ~30-40%. Жоден не виокремлюється — падіння системне, не локальне.', correct: true,
|
||||
feedback: 'Правильно. Коли всі підрозділи рухаються в один бік приблизно однаково — причина системна, не в окремому підрозділі. Не варто шукати "винного" — варто шукати фактор, що вплинув на всіх.' },
|
||||
{ text: '1 БТГр впала найсильніше — там проблема', correct: false,
|
||||
feedback: 'Подивись на діаграму: відмінності між підрозділами невеликі (5-10 п.п.). Усі впали приблизно однаково. Точкове "хто винен" — невірний інстинкт тут.' },
|
||||
{ text: 'Інженерно-сапна рота тримається — у них кращі практики', correct: false,
|
||||
feedback: 'Усі підрозділи впали в межах +/- 10 п.п. одного значення. Локальні відмінності є, але вони шумові, не визначальні.' },
|
||||
{ text: 'Ні — кожен топовий вузол просів на 25-45%. Причина системна, а не локальна.', correct: true,
|
||||
feedback: 'Правильно. Коли патерн рівномірний по всіх значеннях, цей вимір не пояснює варіацію. Перебирати 800+ вузлів по одному — змарновані зусилля.' },
|
||||
{ text: 'Кілька вузлів витягли більшу частину спаду — слід дослідити саме їх', correct: false,
|
||||
feedback: 'Подивись на графік: жоден топовий вузол не виглядає особливо. Усі просіли значно. Жоден окремий вузол не виділяється як причина. Спад розподілений рівномірно.' },
|
||||
{ text: 'Найсильніше просіли вузли в найгарячіших ділянках — це і є зачіпка', correct: false,
|
||||
feedback: 'Усі вузли просіли на схожий відсоток. Причина системна — те саме, що впливає на всі локації.' },
|
||||
]
|
||||
},
|
||||
|
||||
riders_reveal: {
|
||||
prompt: 'Поглянь на 2-сегментний розклад: підготовчі (тренування+логістика) проти бойових (розвідка+вогневе ураження+оборонні). Що сталося?',
|
||||
requesters_reveal: {
|
||||
prompt: 'Частка передових упала з 33% до 27%. Що показує розбивка за типом замовника?',
|
||||
insightKey: 'casualDropMore',
|
||||
options: [
|
||||
{ text: 'Бойові операції різко скоротились (з ~85% до ~50%); підготовчі зросли (з ~15% до ~50%). Структура операційного навантаження кардинально змінилась.', correct: true,
|
||||
feedback: 'Правильно. Бойові операції в листопаді зменшились більше ніж удвічі за абсолютним обсягом, при цьому частка впала з 85% до 50%. Підготовчі натомість виросли. Заголовок "ми ефективніші" — насправді "ми робимо менше складного". Тепер питання: чи кожен сегмент став ефективнішим, чи цифра рухається лише через зміну складу?' },
|
||||
{ text: 'Обидва сегменти впали приблизно однаково — це загальне зниження активності', correct: false,
|
||||
feedback: 'Подивись на частки: бойові з 85% до 50% — це втрата більш ніж третини. Підготовчі натомість *виросли* з 15% до 50%. Це не однакове падіння.' },
|
||||
{ text: 'Підготовчі впали — звідси й нижчий обсяг', correct: false,
|
||||
feedback: 'Навпаки — підготовчі в листопаді *виросли* в абсолютному обсязі, бо їх частка стрибнула з 15% до 50%. Впали саме бойові.' },
|
||||
{ text: 'Падіння рейсів сконцентроване в передових (-44%) — тилові відносно стабільні (-27%). Композиція замовників значно змістилася.', correct: true,
|
||||
feedback: 'Правильно. Рейси передових впали на -44%, а тилових лише на -27%. Заголовок «рейсів менше на 32%» приховує, що більшість шкоди — в одному сегменті. Цей зсув композиції тягне за собою наслідки для будь-якої метрики, що відрізняється між сегментами.' },
|
||||
{ text: 'Обидві групи просіли значно, тому це загальносистемна проблема', correct: false,
|
||||
feedback: 'Подивись на темпи: -44% проти -27% — велика різниця. Передові просіли майже вдвічі швидше. Загальна цифра приховує дуже нерівномірне падіння по групах.' },
|
||||
{ text: 'Тилові тягнуть спад, бо їх більше', correct: false,
|
||||
feedback: 'Тилових більше в абсолютних числах, але їхній відсоток падіння значно менший. Сегмент передових скоротився набагато драматичніше, що змінило загальну композицію.' },
|
||||
]
|
||||
},
|
||||
|
||||
riders_mix: {
|
||||
prompt: 'Поглянь на ефективність *окремо* кожного сегменту через час. Що спостерігаєш?',
|
||||
requesters_mix: {
|
||||
prompt: 'Композиція замовників змістилася. Що це означає для метрики середнього часу обороту?',
|
||||
insightKey: 'mixShift',
|
||||
options: [
|
||||
{ text: 'Підготовчі стабільно близько 83% успіху, бойові — стабільно близько 58%. Жоден сегмент не покращився. Уся "перемога" в загальній цифрі — за рахунок зростання частки підготовчих.', correct: true,
|
||||
feedback: 'Правильно. Усередині кожного сегменту нічого не змінилось. Обидва тримаються своєї історичної ефективності. Загальний показник зріс не тому, що ми краще робимо роботу — а тому, що *більшу частку* стала займати робота, яку ми завжди робили краще. Це класичний *зсув композиції*.' },
|
||||
{ text: 'Підготовчі покращились, тому й загальна цифра вища', correct: false,
|
||||
feedback: 'Подивись на криву підготовчих окремо — вона рівна, без значного зростання. Загальна цифра вища через зміну ваги, не через зміну продуктивності.' },
|
||||
{ text: 'Бойові стали ефективнішими — особливо помітно у листопаді', correct: false,
|
||||
feedback: 'Ефективність бойових тримається на ~58% — як у попередніх місяцях. У листопаді вона *не* стрибнула. Загальна цифра рухається лише через перерозподіл часток.' },
|
||||
{ text: 'Передові беруть значно довші рейси. Якщо їхня частка скоротилася з 33% до 27%, загальний середній час обороту впаде, навіть якщо жодна група не змінила своєї поведінки.', correct: true,
|
||||
feedback: 'Правильно! Коли композиція змінюється, агреговані метрики зміщуються, навіть якщо поведінка жодної групи не змінилася. Це і є ключ до падіння середнього часу обороту. Подивись на вкладку «Час обороту», щоб побачити це в дії.' },
|
||||
{ text: 'Зміна композиції замала (6 п.п.), щоб помітно вплинути на середнє', correct: false,
|
||||
feedback: 'Передові в середньому ~24 хв на рейс проти ~12 хв у тилових. Зсув на 6 п.п. у групи, яка обертається вдвічі довше, дає великий ефект на зважене середнє. Перевір математику на вкладці «Час обороту».' },
|
||||
{ text: 'Час обороту і кількість рейсів — окремі метрики; зсув композиції впливає тільки на кількість', correct: false,
|
||||
feedback: 'Зсув композиції впливає на БУДЬ-ЯКУ метрику, що відрізняється між групами. Оскільки передові обертаються вдвічі довше за тилових, зміна частки передових прямо змінює середній час обороту.' },
|
||||
]
|
||||
},
|
||||
|
||||
duration_paradox: {
|
||||
prompt: 'Загальна ефективність зросла з 62% до 71%, але кожен сегмент окремо — не змінився. Як таке може бути?',
|
||||
prompt: 'Загальний час обороту впав на -11,9%, але подивись на кожну групу окремо. Що відбувається?',
|
||||
insightKey: 'durationParadox',
|
||||
options: [
|
||||
{ text: 'Жодний сегмент не змінився, але змінились їхні частки. Підготовчі (з ~83% успіху) стали більшою часткою; бойові (з ~58% успіху) — меншою. Загальна цифра зросла за рахунок зсуву ваги, не за рахунок поведінки.', correct: true,
|
||||
feedback: 'Правильно! Це й є *зсув композиції* (відомий також як *парадокс Сімпсона*). Загальна цифра — це зважене середнє. Якщо ваги рухаються до сегменту з вищим показником, загальна цифра зростає, навіть коли всередині кожного сегменту нічого не змінилось. У звіті командирові: "ми не стали ефективнішими — ми просто робимо менше складного".' },
|
||||
{ text: 'Холодна погода / зима покращує бойові операції', correct: false,
|
||||
feedback: 'Ні — ефективність бойових у листопаді ~58%, як і в інших місяцях. Сезонність не пояснює стрибок загальної цифри.' },
|
||||
{ text: 'Це випадкове коливання — нічого особливого', correct: false,
|
||||
feedback: 'Стрибок з 62% до 71% — це 9 п.п. на ~500 операціях; це не шум. До того ж є дзеркальна ситуація з частками сегментів. Це структура, не випадковість.' },
|
||||
{ text: 'Жодна група сама по собі не змінилася сильно, але загальне середнє впало, бо передові (які обертаються довше) стали меншою часткою композиції', correct: true,
|
||||
feedback: 'Правильно! Це парадокс Сімпсона. Тилові (~73% рейсів у листопаді) майже не змінилися (-4,8%), передові просіли деякою мірою (-13,2%), а загальне впало непропорційно сильно, бо група «довгих» стиснулася в композиції.' },
|
||||
{ text: 'Холод змушує всіх обертатися швидше — спад реальний у всіх групах', correct: false,
|
||||
feedback: 'Якби холод укорочував рейси рівномірно, обидві групи показали б подібне падіння. Але тилові просіли лише на -4,8%, а загальне — на -11,9%. Цей розрив погодою не пояснити.' },
|
||||
{ text: 'Час обороту просів пропорційно по всіх групах — нічого незвичайного', correct: false,
|
||||
feedback: 'Порівняй цифри: тилові -4,8%, передові -13,2%, загальне -11,9%. Якщо тилових 73%, то загальне мало б бути ближчим до -4,8%. Загальне тягне вниз саме зміна композиції, а не індивідуальна поведінка.' },
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
const OBSERVATIONS = [
|
||||
{ key: 'yoyPositive', text: 'Стрибок з 62% до 71% — справжній. Минулорічний листопад мав ~62%; цей рік перший з такою цифрою.', tag: 'observation' },
|
||||
{ key: 'longRidesCollapse', text: 'За масштабом операцій падіння обсягу рівномірне (~30-40%) — масштаб не пояснює зміну ефективності.', tag: 'observation' },
|
||||
{ key: 'casualDropMore', text: 'Бойові операції різко скоротились (частка 85% → 50%); підготовчі виросли (15% → 50%) — структура навантаження змінилась.', tag: 'observation' },
|
||||
{ key: 'mixShift', text: 'Частка підготовчих операцій стрибнула з 15% до 50% — мікс операцій кардинально зсунувся.', tag: 'observation' },
|
||||
{ key: 'durationParadox', text: 'Ефективність кожного сегменту окремо — стабільна; зростає тільки загальна цифра. Класична зміна композиції.', tag: 'observation' },
|
||||
{ key: 'yoyPositive', text: 'Рейси листопада насправді ВИЩІ, ніж торік (+7,6%) — діяльність зростає', tag: 'observation' },
|
||||
{ key: 'longRidesCollapse', text: 'Довгі рейси зникли непропорційно (-58% для 60+ хв проти -23% для коротких)', tag: 'observation' },
|
||||
{ key: 'casualDropMore', text: 'Падіння рейсів сконцентроване в передових (-44%) — тилові відносно стабільні (-27%)', tag: 'observation' },
|
||||
{ key: 'mixShift', text: 'Передові скоротилися з 33% до 27% від усіх рейсів — композиція замовників змістилася', tag: 'observation' },
|
||||
{ key: 'durationParadox', text: 'Час обороту в кожній групі майже не змінився, але загальне середнє впало значно більше — парадокс Сімпсона', tag: 'observation' },
|
||||
];
|
||||
|
||||
const CONCLUSIONS = [
|
||||
{ key: 'conclusionSeasonal', text: 'Падіння обсягу операцій — структурний зсув (бойові скоротили), не операційна проблема й не чисто сезонність.', tag: 'conclusion' },
|
||||
{ key: 'conclusionParadox', text: 'Зростання загальної ефективності — ефект композиції, не реальне покращення продуктивності.', tag: 'conclusion' },
|
||||
{ key: 'conclusionSeasonal', text: 'Падіння рейсів — звичайна сезонність, а не операційна проблема', tag: 'conclusion' },
|
||||
{ key: 'conclusionParadox', text: 'Падіння часу обороту — ефект композиції (парадокс Сімпсона), а не реальна зміна поведінки', tag: 'conclusion' },
|
||||
];
|
||||
|
||||
// Dead-end lesson labels for the summary screen
|
||||
// Підписи «глухих кутів» для підсумкового екрану
|
||||
const LESSON_NAMES = {
|
||||
dayofweek: 'День тижня — перевіряй календарну структуру: різна кількість буднів/вихідних може створити фальшиві патерни.',
|
||||
hourly: 'Однакова форма за годинами — це зміна обсягу, не патерну. Шукай розрізи, де *форма* розподілу змінюється.',
|
||||
biketypes: 'Категорії рухаються в різні боки — це не однорідне падіння, а структурна зміна. Перевіряй частки.',
|
||||
stations: 'Усі підрозділи впали схоже — причина системна, не локальна. Не шукай "винного" — шукай фактор, що вплинув на всіх.',
|
||||
dayofweek: 'Сирі суми по днях тижня вводять в оману — нормалізуй на календарну структуру',
|
||||
hourly: 'Однакова форма погодинної кривої — зсув обсягу, а не патерну',
|
||||
vehicletypes: 'Класи техніки падають разом — не причина',
|
||||
stations: 'Усі вузли просіли однаково — системно, не локально',
|
||||
};
|
||||
|
||||
-8541
File diff suppressed because it is too large
Load Diff
+182
@@ -0,0 +1,182 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--primary: #003964;
|
||||
--blue: #00BBCE;
|
||||
--green: #A7C539;
|
||||
--red: #F15B43;
|
||||
--font: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--left-w: 360px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--font); background: #f4f4f8; color: #333; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Header */
|
||||
.header { background: var(--primary); color: white; padding: 8px 20px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.header h1 { font-size: 15px; font-weight: 600; }
|
||||
.header-right { display: flex; align-items: center; gap: 14px; }
|
||||
.views-tag { font-size: 12px; opacity: 0.75; }
|
||||
.restart-btn { background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.3); padding: 4px 10px; border-radius: 5px; cursor: pointer; font-size: 11px; font-family: var(--font); }
|
||||
.restart-btn:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* App layout */
|
||||
.app { flex: 1; display: grid; grid-template-columns: var(--left-w) 1fr; overflow: hidden; }
|
||||
|
||||
/* Left panel */
|
||||
.left-panel { display: flex; flex-direction: column; border-right: 1px solid #ddd; background: #fff; overflow: hidden; }
|
||||
|
||||
.vp-section { padding: 14px 16px; border-bottom: 1px solid #eee; flex-shrink: 0; }
|
||||
.vp-msg { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||
.vp-avatar { width: 32px; height: 32px; border-radius: 6px; background: var(--primary); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: white; flex-shrink: 0; }
|
||||
.vp-content { flex: 1; min-width: 0; }
|
||||
.vp-name { font-size: 12px; font-weight: 600; color: var(--primary); }
|
||||
.vp-title { font-size: 11px; color: #999; }
|
||||
.vp-text { font-size: 13px; line-height: 1.45; color: #333; margin-top: 3px; }
|
||||
|
||||
.disclaimer-box { background: #fff8e1; border-left: 3px solid #ffb300; border-radius: 6px; padding: 8px 10px; margin: 0 0 10px; font-size: 11px; line-height: 1.45; color: #5b4400; }
|
||||
.disclaimer-box strong { color: #b06700; }
|
||||
|
||||
.alert-metrics { display: flex; gap: 8px; }
|
||||
.alert-metric { flex: 1; background: #fef2f0; border: 1px solid #fcd5cf; border-radius: 6px; padding: 8px 10px; }
|
||||
.alert-metric .am-label { font-size: 10px; text-transform: uppercase; color: #888; letter-spacing: 0.3px; }
|
||||
.alert-metric .am-val { font-size: 18px; font-weight: 700; color: var(--primary); }
|
||||
.alert-metric .am-delta { font-size: 12px; font-weight: 600; color: var(--red); }
|
||||
|
||||
/* Insights */
|
||||
.insights-section { flex: 1; overflow-y: auto; padding: 12px 16px; }
|
||||
.insights-heading { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; margin-bottom: 8px; font-weight: 600; }
|
||||
|
||||
.insight-card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; cursor: default; transition: all 0.3s ease; position: relative; }
|
||||
|
||||
/* Locked: fully hidden content, just a placeholder */
|
||||
.insight-card.locked { background: #f8f8f8; border-color: #eee; }
|
||||
.insight-placeholder { font-size: 12px; color: #bbb; font-style: italic; }
|
||||
.insight-card.locked .insight-check { display: none; }
|
||||
|
||||
/* Unlocked: visible content */
|
||||
.insight-card.unlocked { opacity: 1; border-color: var(--blue); background: #f0fbfd; animation: unlockPulse 0.6s ease; }
|
||||
.insight-card.unlocked.conclusion { border-color: var(--green); background: #f4f9e8; }
|
||||
@keyframes unlockPulse { 0% { box-shadow: 0 0 0 0 rgba(0,187,206,0.4); } 70% { box-shadow: 0 0 0 8px rgba(0,187,206,0); } 100% { box-shadow: none; } }
|
||||
|
||||
.insight-text { font-size: 13px; line-height: 1.4; }
|
||||
.insight-tag { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-top: 4px; }
|
||||
.insight-tag.observation { background: #e3f2fd; color: #1565c0; }
|
||||
.insight-tag.conclusion { background: #e8f5e9; color: #2e7d32; }
|
||||
|
||||
.insight-check { position: absolute; right: 10px; top: 10px; width: 20px; height: 20px; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 13px; color: white; background: white; transition: all 0.2s; }
|
||||
.insight-card.unlocked.conclusion .insight-check { display: flex; border-color: var(--green); }
|
||||
.insight-card.unlocked:not(.conclusion) .insight-check { display: none; }
|
||||
.insight-check.checked { background: var(--green); border-color: var(--green); }
|
||||
|
||||
/* Connection card */
|
||||
.connection-card { background: #fff8e1; border-left: 3px solid #ffc107; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; font-size: 12px; line-height: 1.5; color: #555; animation: unlockPulse 0.6s ease; }
|
||||
.connection-card strong { color: var(--primary); }
|
||||
|
||||
/* Submit */
|
||||
.submit-section { padding: 12px 16px; border-top: 1px solid #eee; flex-shrink: 0; }
|
||||
.submit-btn { width: 100%; padding: 10px; border: none; border-radius: 7px; font-size: 14px; font-weight: 600; font-family: var(--font); cursor: pointer; transition: all 0.2s; }
|
||||
.submit-btn:disabled { background: #e0e0e0; color: #999; cursor: not-allowed; }
|
||||
.submit-btn:not(:disabled) { background: var(--green); color: var(--primary); }
|
||||
.submit-btn:not(:disabled):hover { background: #96b432; }
|
||||
.submit-hint { font-size: 11px; color: #aaa; text-align: center; margin-top: 4px; }
|
||||
|
||||
/* Right panel */
|
||||
.right-panel { display: flex; flex-direction: column; overflow: hidden; background: #f4f4f8; }
|
||||
|
||||
.tab-bar { display: flex; border-bottom: 2px solid #e0e0e0; padding: 0 16px; flex-shrink: 0; background: white; }
|
||||
.tab-btn { padding: 10px 18px; font-size: 13px; font-family: var(--font); font-weight: 600; border: none; background: none; cursor: pointer; color: #888; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s; }
|
||||
.tab-btn:hover { color: var(--primary); }
|
||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--blue); }
|
||||
|
||||
.tab-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
.chart-card { background: white; border: 1px solid #e0e0e0; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
||||
.chart-card h3 { font-size: 14px; color: var(--primary); margin-bottom: 10px; }
|
||||
.chart-card canvas { max-height: 260px; }
|
||||
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.toggle-label { font-size: 12px; color: #888; }
|
||||
.toggle-btn { padding: 6px 14px; border-radius: 20px; font-size: 12px; font-family: var(--font); cursor: pointer; border: 1px solid #ccc; background: white; color: #666; transition: all 0.15s; }
|
||||
.toggle-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
|
||||
/* Sub-tabs */
|
||||
.sub-tab-bar { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.sub-tab-btn { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-family: var(--font); cursor: pointer; border: 1px solid #ddd; background: white; color: #666; transition: all 0.15s; }
|
||||
.sub-tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
|
||||
/* Metrics row */
|
||||
.metrics-row { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.metric-card { flex: 1; min-width: 120px; background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 14px; }
|
||||
.metric-card .mc-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.metric-card .mc-val { font-size: 20px; font-weight: 700; color: var(--primary); margin: 2px 0; }
|
||||
.metric-card .mc-delta { font-size: 12px; font-weight: 600; }
|
||||
.metric-card .mc-delta.pos { color: #2e7d32; }
|
||||
.metric-card .mc-delta.neg { color: var(--red); }
|
||||
|
||||
/* Quiz system */
|
||||
.quiz-section { background: white; border: 1px solid #e0e0e0; border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
|
||||
.quiz-prompt { font-size: 13px; font-weight: 600; color: var(--primary); margin-bottom: 10px; }
|
||||
.quiz-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.quiz-opt { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; background: white; cursor: pointer; font-family: var(--font); font-size: 13px; line-height: 1.4; text-align: left; color: #333; transition: all 0.2s; }
|
||||
.quiz-opt:hover:not(.disabled) { border-color: var(--blue); background: #f8fcfd; }
|
||||
.quiz-opt .q-icon { flex-shrink: 0; width: 20px; height: 20px; border: 2px solid #ccc; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; margin-top: 1px; transition: all 0.2s; }
|
||||
.quiz-opt.correct { border-color: var(--green); background: #f4f9e8; cursor: default; }
|
||||
.quiz-opt.correct .q-icon { border-color: var(--green); background: var(--green); color: white; }
|
||||
.quiz-opt.wrong { border-color: var(--red); background: #fef2f0; }
|
||||
.quiz-opt.wrong .q-icon { border-color: var(--red); background: var(--red); color: white; }
|
||||
.quiz-opt.disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
||||
.quiz-opt.faded { opacity: 0.4; cursor: default; pointer-events: none; }
|
||||
.quiz-feedback { margin-top: 8px; font-size: 12px; line-height: 1.5; color: #555; padding: 8px 10px; border-radius: 6px; animation: fadeIn 0.2s ease; display: none; }
|
||||
.quiz-feedback.visible { display: block; }
|
||||
.quiz-feedback.wrong-fb { background: #fef2f0; border-left: 3px solid var(--red); }
|
||||
.quiz-feedback.correct-fb { background: #f4f9e8; border-left: 3px solid var(--green); }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Lesson cards */
|
||||
.lesson-card { background: #fff8e1; border-left: 3px solid #ffb300; border-radius: 6px; padding: 10px 14px; margin-top: 12px; }
|
||||
.lesson-card h4 { font-size: 12px; color: #e65100; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.lesson-card p { font-size: 13px; line-height: 1.5; color: #555; }
|
||||
.lesson-hidden { display: none; }
|
||||
|
||||
/* Summary overlay */
|
||||
.summary-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; z-index: 100; align-items: center; justify-content: center; }
|
||||
.summary-overlay.visible { display: flex; }
|
||||
.summary-box { background: white; border-radius: 14px; max-width: 720px; width: 90%; max-height: 90vh; overflow-y: auto; padding: 30px; box-shadow: 0 8px 40px rgba(0,0,0,0.2); animation: slideUp 0.3s ease; }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; } }
|
||||
|
||||
.summary-box h2 { color: var(--primary); margin-bottom: 6px; }
|
||||
.summary-score { text-align: center; margin: 16px 0 24px; }
|
||||
.summary-score .big { font-size: 42px; font-weight: 700; color: var(--primary); display: block; }
|
||||
.summary-score .sub { font-size: 14px; color: #888; }
|
||||
.s-card { background: #f8f9fa; border-radius: 10px; padding: 16px 18px; margin-bottom: 14px; }
|
||||
.s-card h3 { font-size: 15px; color: var(--primary); margin-bottom: 6px; }
|
||||
.s-card p { font-size: 14px; line-height: 1.55; color: #444; margin-bottom: 6px; }
|
||||
.s-card p:last-child { margin-bottom: 0; }
|
||||
.s-card.connection { background: #fff8e1; border-left: 3px solid #ffc107; }
|
||||
.s-card.bridge { background: #e8f5e9; border-left: 3px solid var(--green); }
|
||||
.s-card.reframe { background: #e3f2fd; border-left: 3px solid var(--blue); }
|
||||
.s-card.discussion { background: #f3f3f3; }
|
||||
.s-card.discussion p { margin-bottom: 4px; }
|
||||
.s-card .s-metrics { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.s-card .s-metric { background: white; border: 1px solid #e0e0e0; border-radius: 6px; padding: 8px 12px; flex: 1; min-width: 100px; text-align: center; }
|
||||
.s-card .s-metric .sm-label { font-size: 10px; color: #888; text-transform: uppercase; }
|
||||
.s-card .s-metric .sm-val { font-size: 18px; font-weight: 700; color: var(--primary); }
|
||||
.s-card .s-metric .sm-delta { font-size: 12px; font-weight: 600; }
|
||||
|
||||
.summary-actions { text-align: center; margin-top: 20px; }
|
||||
.summary-actions button { padding: 10px 28px; border: none; border-radius: 7px; font-size: 14px; font-weight: 600; font-family: var(--font); cursor: pointer; background: var(--primary); color: white; }
|
||||
.summary-actions button:hover { background: #004a7c; }
|
||||
|
||||
.bonus-list { list-style: none; padding: 0; }
|
||||
.bonus-list li { font-size: 13px; padding: 2px 0; color: #555; }
|
||||
.bonus-list li::before { content: '\2713 '; color: var(--green); font-weight: 700; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
|
||||
.right-panel { order: -1; }
|
||||
.left-panel { max-height: 40vh; }
|
||||
:root { --left-w: 100%; }
|
||||
}
|
||||
Reference in New Issue
Block a user