2PlayerGame/detective-game/public/analyst.js
UndecimoDia cb93544793 Fixes
2026-08-15 18:04:59 +03:00

502 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія.
'use strict';
(() => {
let stage, panels = {}, mapCtx, mapCv;
const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя';
const caesarDec = (text, s) => [...text].map(ch => {
const low = ch.toLowerCase(), i = UKR.indexOf(low);
if (i < 0) return ch;
const out = UKR[(i - s % UKR.length + UKR.length) % UKR.length];
return ch === low ? out : out.toUpperCase();
}).join('');
const b64dec = s => { try { return decodeURIComponent(escape(atob(s.trim()))); } catch { return '⚠ не схоже на base64'; } };
App.on('init', m => {
if (App.role !== 'analyst') return;
stage = document.getElementById('stage');
stage.innerHTML = '';
const tabs = el('div', 'atabs');
const names = { db: 'База мешканців', map: 'Мапа району', sys: 'Системи й лабораторія' };
for (const id of Object.keys(names)) {
const b = el('button', id === 'db' ? 'on' : '', names[id]);
b.onclick = () => {
for (const x of tabs.children) x.classList.toggle('on', x === b);
for (const p of Object.values(panels)) p.classList.remove('on');
panels[id].classList.add('on');
};
tabs.append(b);
panels[id] = el('div', 'apanel' + (id === 'db' ? ' on' : ''));
}
stage.append(tabs, panels.db, panels.map, panels.sys);
buildDb(); buildMap(); buildSys();
setInterval(() => {
if (!panels.map.classList.contains('on')) return;
if (!mapCv._k) fitCanvas(mapCv);
drawDistrict(mapCtx, { partner: true });
}, 400);
addEventListener('resize', () => { if (mapCv) { fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); } });
});
App.on('pos', () => { if (mapCtx) drawDistrict(mapCtx, { partner: true }); });
App.on('state', () => { if (App.role !== 'analyst' || !stage) return; renderPuzzles(); renderRows(); renderLab(); renderAlibi(); });
// ---------- База мешканців ----------
// Сліди в базу не підставляються самі: читайте докази і вводьте значення руками.
let dbOut = null, lastRows = null;
let dbShowHidden = false; // згорнута секція прихованих у базі
function buildDb() {
const p = panels.db;
p.innerHTML = `
<h3>База мешканців району</h3>
<p class="dim small">Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<!-- Два незалежні запити, а не один із додатковими полями: пошук за іменем
і пошук за прикметами не поєднуються й перебивають один одного. Тому
вони рознесені підписами і рискою — раніше два рядки полів підряд
читались як одна форма з кнопкою «Знайти» вгорі. -->
<div class="dbsec">Пошук за іменем</div>
<p class="dim small">Досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище).</p>
<div class="filters">
<input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="q-go" class="primary">Знайти</button>
</div>
<div class="dbsplit"></div>
<div class="dbsec">Пошук за прикметами</div>
<p class="dim small">Значення беріть зі знайдених слідів і вводьте самі. Порожні поля не враховуються.</p>
<div class="filters">
<select id="f-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="f-smin" type="number" placeholder="взуття від" min="35" max="46">
<input id="f-smax" type="number" placeholder="взуття до" min="35" max="46">
<input id="f-hmin" type="number" placeholder="зріст від">
<input id="f-hmax" type="number" placeholder="зріст до">
<select id="f-print"><option value="">відбитки: всі</option><option>дуга</option><option>петля</option><option>завиток</option></select>
<select id="f-coat"><option value="">одяг: будь-який</option>${[...new Set(App.init.citizens.map(c => c.coat))].map(x => `<option>${esc(x)}</option>`).join('')}</select>
<label class="small dim"><input type="checkbox" id="f-smoke"> курить</label>
<button id="f-go">Фільтрувати</button>
</div>
<div id="db-out"></div>`;
dbOut = p.querySelector('#db-out');
// Enter шукає / фільтрує — щоб не тягнутись мишею до кнопки на кожен із
// десятків пошуків за гру. Числові поля фільтрів теж: набрав розмір взуття,
// натиснув Enter. У <select> потреби немає — вони й так змінюються з клавіатури.
submitOn(p.querySelector('#q-name'), p.querySelector('#q-go'));
for (const id of ['#f-smin', '#f-smax', '#f-hmin', '#f-hmax'])
submitOn(p.querySelector(id), p.querySelector('#f-go'));
p.querySelector('#q-go').onclick = () => {
const q = p.querySelector('#q-name').value.trim();
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
lastRows = matchName(App.init.citizens, q); // спільний пошук, див. client.js
if (!lastRows.length) {
dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>';
return;
}
renderRows();
};
p.querySelector('#f-go').onclick = () => {
const v = id => p.querySelector(id).value;
lastRows = App.init.citizens.filter(c =>
(!v('#f-sex') || c.sex === v('#f-sex')) &&
(!v('#f-smin') || c.shoe >= +v('#f-smin')) &&
(!v('#f-smax') || c.shoe <= +v('#f-smax')) &&
(!v('#f-hmin') || c.height >= +v('#f-hmin')) &&
(!v('#f-hmax') || c.height <= +v('#f-hmax')) &&
(!v('#f-print') || c.printType === v('#f-print')) &&
(!v('#f-coat') || c.coat === v('#f-coat')) &&
(!p.querySelector('#f-smoke').checked || c.smoker));
renderRows();
};
}
// рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині
function renderRows() {
if (!dbOut || !lastRows || !App.state) return;
dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
const t = el('table', '', '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th>Зріст</th><th>Взуття</th><th>Відбитки</th><th></th></tr>');
const hidden = [];
for (const c of lastRows) {
const key = 'c:' + c.id;
if (App.state.board.marks[key] === 'hide') { hidden.push(c); continue; }
const tr = el('tr', 'rowlink' + cardClasses(key), `<td>${esc(c.name)}${App.state.victim === c.id ? (App.init.caseType === 'kidnap' ? ' ⚠' : ' †') : ''}</td><td>${c.age}</td><td>${c.home}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${c.printType}</td>`);
const mk = el('td');
mk.onclick = ev => ev.stopPropagation();
mk.append(markButtons(key));
tr.append(mk);
tr.onclick = () => showDossier(c);
t.append(tr);
}
dbOut.append(t);
if (hidden.length) {
const head = el('button', 'small', `${dbShowHidden ? '▾' : '▸'} Приховані (${hidden.length})`);
head.style.cssText = 'width:100%;margin-top:10px;text-align:left';
head.onclick = () => {
dbShowHidden = !dbShowHidden;
renderRows();
};
dbOut.append(head);
if (dbShowHidden) {
const ht = el('table', '', '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th></th></tr>');
for (const c of hidden) {
const tr = el('tr', 'rowlink dim', `<td>${esc(c.name)}</td><td>${c.age}</td><td>${c.home}</td><td>${esc(c.job)}</td>`);
const showBtn = el('button', 'small', 'Показати');
showBtn.onclick = (ev) => {
ev.stopPropagation();
App.send({ t: 'mark', key: 'c:' + c.id, kind: 'hide' }); // зняти hide
};
const td = el('td'); td.append(showBtn);
tr.append(td);
ht.append(tr);
}
dbOut.append(ht);
}
}
}
// ---------- Мапа ----------
function buildMap() {
const p = panels.map;
p.innerHTML = '<h3>Мапа району</h3><p class="dim small">Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.</p>';
mapCv = el('canvas'); mapCv.id = 'cv';
mapCv.width = App.init.district.world.w; mapCv.height = App.init.district.world.h;
p.append(mapCv);
mapCtx = mapCv.getContext('2d');
drawDistrict(mapCtx, { partner: true });
mapCv.onclick = e => {
const r = mapCv.getBoundingClientRect(), D = App.init.district.world;
const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height;
const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h);
if (b) buildingInfo(b);
else App.send({ t: 'marker', x, y });
};
}
function buildingInfo(b) {
const res = App.init.citizens.filter(c => b.units && b.units.includes(c.home));
const work = App.init.citizens.filter(c => c.workplace === b.id);
const here = App.init.citizens.filter(c => App.state && App.state.locs[c.id] === b.id && c.id !== App.state.victim);
let html = `<h3>${esc(b.name)}</h3>`;
if (res.length) html += '<div class="syshead">Мешканці</div>' + res.map(c => `<div class="small">${c.home}: ${esc(c.name)}</div>`).join('');
if (work.length) html += '<div class="syshead">Працюють</div>' + work.map(c => `<div class="small">${esc(c.name)}${esc(c.job)}</div>`).join('');
html += `<div class="syshead">Зараз тут</div><div class="small dim">${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}</div>`;
openModal(html, { key: 'building:' + b.id });
}
// ---------- Системи-форми ----------
// Кожен запит вводиться руками; результат — навіть дрібниця — лягає на дошку.
let pzList = null, labBox = null;
const q$ = (p, sel) => p.querySelector(sel);
const fmtT = v => `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`;
function buildSys() {
const p = panels.sys;
p.innerHTML = `
<h3>Системи й лабораторія</h3>
<p class="dim small">Дані для запитів — номери, імена, прикмети, роки — вичитуйте з доказів і вводьте самі. Кожен результат лягає на дошку: що з нього стосується справи — вирішуєте ви.</p>
<div class="syshead">Оператор зв'язку</div>
<div class="filters">
<input id="ph-n" placeholder="+380…" style="min-width:200px">
</div>
<p class="dim small">Формат номера вільний: пробіли, дужки й дефіси система відкидає сама.</p>
<div class="filters">
<button data-q="owner">Архів: власник номера</button>
<button data-q="calls">Архів: журнал дзвінків за вечір</button>
</div>
<div class="filters">
<button data-q="locate">📍 Тріангуляція — де апарат ЗАРАЗ</button>
</div>
<p class="dim small">Перші два запити — архівні, про вечір злочину. Пеленг — живий: показує поточну позицію апарата, і оператор дає його лише за наявності підстав.</p>
<div class="syshead">Банк</div>
<div class="filters">
<input id="bk-n" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="bk-go">Запросити виписку</button>
</div>
<p class="dim small">Досить початку імені чи прізвища — те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.</p>
<div class="syshead">Реєстри</div>
<div class="filters">
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="min-width:220px">
<button id="db-go">Пошук у реєстрах</button>
</div>
<div class="syshead">Криміналістика</div>
<div class="filters">
<button id="cr-exp" class="primary">Експертиза місця/тіла</button>
<input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="min-width:220px">
<button id="cr-go">Пошук в архіві справ</button>
</div>
<div class="syshead">Камери — архів ночі злочину</div>
<div class="filters">
<select id="cc-cam">${App.init.district.cameras.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('')}</select>
<input type="range" id="cc-t" min="1200" max="1439" value="1290" style="width:180px">
<span id="cc-v" style="font-family:monospace"></span>
<button id="cc-go">Переглянути ±10 хв</button>
</div>
<div class="syshead">Зведення алібі</div>
<div id="al-table" class="small dim">Ще нікого не питали, де він був того вечора.</div>
<div class="syshead">Лабораторія</div>
<div class="filters" id="lab-btns"></div>
<div id="pz-list"></div>
<details style="margin-top:10px"><summary class="dim small">Інструменти дешифрування (Base64, Цезар)</summary>
<div class="card">
<div class="syshead">Base64</div>
<textarea id="tb64" rows="2" style="width:100%" placeholder="вставте рядок…"></textarea>
<button id="tb64go">Розкодувати</button>
<div id="tb64out" class="small" style="word-break:break-all"></div>
<div class="syshead">Цезар — перебір усіх зсувів</div>
<textarea id="tcz" rows="2" style="width:100%" placeholder="вставте текст…"></textarea>
<button id="tczgo">Показати всі 32</button>
<pre id="tczout" class="small" style="white-space:pre-wrap;max-height:220px;overflow:auto"></pre>
</div>
</details>`;
for (const b of p.querySelectorAll('[data-q]'))
b.onclick = () => App.send({ t: 'sys_query', sys: 'phone', kind: b.dataset.q, input: q$(p, '#ph-n').value });
q$(p, '#bk-go').onclick = () => App.send({ t: 'sys_query', sys: 'bank', input: q$(p, '#bk-n').value });
q$(p, '#db-go').onclick = () => App.send({ t: 'sys_query', sys: 'db', input: q$(p, '#db-n').value });
q$(p, '#cr-exp').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'expert' });
q$(p, '#cr-go').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'archive', input: q$(p, '#cr-n').value });
const sl = q$(p, '#cc-t'), out = q$(p, '#cc-v');
const upd = () => out.textContent = fmtT(+sl.value);
sl.oninput = upd; upd();
q$(p, '#cc-go').onclick = () => App.send({ t: 'sys_query', sys: 'cctv', camera: q$(p, '#cc-cam').value, minutes: +sl.value });
q$(p, '#tb64go').onclick = () => q$(p, '#tb64out').textContent = b64dec(q$(p, '#tb64').value);
q$(p, '#tczgo').onclick = () => {
const s = q$(p, '#tcz').value;
q$(p, '#tczout').textContent = s.trim()
? Array.from({ length: 32 }, (_, i) => `${String(i + 1).padStart(2, ' ')}${caesarDec(s, i + 1)}`).join('\n') : '';
};
pzList = q$(p, '#pz-list');
labBox = q$(p, '#lab-btns');
renderLab();
}
// Зведення того, що люди КАЖУТЬ про свій вечір. Суперечності не підсвічуються —
// порівнювати покази з архівами камер гравці мають самі.
function renderAlibi() {
const box = panels.sys && panels.sys.querySelector('#al-table');
if (!box || !App.state) return;
const claims = Object.entries(App.state.alibiClaims || {});
if (!claims.length) { box.innerHTML = 'Ще нікого не питали, де він був того вечора.'; box.className = 'small dim'; return; }
box.className = '';
const bn = id => { const b = App.init.district.buildings.find(x => x.id === id); return b ? b.name.split(',')[0] : id; };
const t = el('table', '', '<tr><th>Хто</th><th>Каже, що був</th><th>Камери над тим місцем</th><th></th></tr>');
for (const [cid, claim] of claims) {
const c = App.byId(cid);
// Той самий ключ, що й у базі мешканців: галочка/викреслення на особі — спільні.
const key = 'c:' + cid;
if (App.state.board.marks[key] === 'hide') continue; // сховане в базі — сховане й тут
// Зізнався, що збрехав → показуємо перехід «стара заява → реальне місце»,
// і камери підтягуємо вже над реальним місцем (обидві колонки синхронно).
const real = (App.state.alibiReal || {})[cid];
const place = real || claim;
const said = real ? `${esc(bn(claim))}${esc(bn(real))}` : esc(bn(claim));
const cams = App.init.district.cameras.filter(cam => cam.covers.includes(place));
const cell = cams.length
? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('<br>')
: 'жодна камера туди не дивиться';
const tr = el('tr', cardClasses(key), `<td>${esc(c.name)}</td><td>${said}</td><td class="small">${cell}</td>`);
const mk = el('td'); mk.append(markButtons(key)); tr.append(mk);
t.append(tr);
}
box.innerHTML = ''; box.append(t);
}
function renderLab() {
if (!labBox || !App.state) return;
labBox.innerHTML = '';
const foundId = id => App.state.found.some(f => f.id === id);
// Обрізаємо з кінця по символах, а не по трьох словах: два описи ДНК часто
// починаються однаково («волосся, вирване з коренем…»), і кнопки ставали
// нерозрізненними. Повний текст лишається в title=.
const short = (t, n = 40) => !t ? '' : t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t;
const mk = (label, sample, doneId, noSampleMsg, run) => {
const b = el('button', '', short(label) + (foundId(doneId) ? ' ✓' : ''));
b.title = label;
b.disabled = foundId(doneId);
b.onclick = () => sample == null ? toast(noSampleMsg) : run();
labBox.append(b);
};
mk(App.state.dnaLabel ? `ДНК: ${App.state.dnaLabel}` : 'Порівняти ДНК зі зразком',
App.state.dnaSample, 'lab_dna_match',
'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.',
() => pickCompare('Гель-електрофорез: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna', App.state.dnaSample, nav)));
if (App.state.dnaSample2)
mk(`ДНК (2): ${App.state.dna2Label}`, App.state.dnaSample2, 'lab_dna_match2',
'',
() => pickCompare('Другий зразок: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna2', App.state.dnaSample2, nav)));
mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match',
'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).',
() => pickCompare('Дактилоскопія: чиї картки підняти?', printModal));
}
// ---------- Шифри (у лабораторії) ----------
let pzSig = null;
function renderPuzzles() {
if (!pzList) return;
const sig = App.state.puzzles.map(x => x.id + x.solved).join();
if (sig === pzSig) return; pzSig = sig;
pzList.innerHTML = '';
if (!App.state.puzzles.length) pzList.append(el('p', 'dim small', 'Зашифрованих знахідок поки немає.'));
for (const z of App.state.puzzles) {
const c = el('div', 'card' + (z.solved ? ' tick' : ''));
c.append(el('div', 't', `<b>${z.solved ? '✓ ' : ''}${esc(z.prompt)}</b>`));
if (z.solved) { pzList.append(c); continue; }
if (z.kind === 'caesar') {
c.append(el('div', 'small', `<code>${esc(z.payload)}</code>`));
const out = el('div', 'small', '');
const sl = el('input'); sl.type = 'range'; sl.min = 1; sl.max = 32; sl.value = 1; sl.style.width = '100%';
const upd = () => out.innerHTML = `зсув ${sl.value}: <code>${esc(caesarDec(z.payload, +sl.value))}</code>`;
sl.oninput = upd; upd();
const b = el('button', 'primary', 'Зафіксувати зсув');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, shift: +sl.value });
c.append(sl, out, b);
} else if (z.kind === 'b64') {
c.append(el('div', 'small', `<code>${esc(z.payload)}</code>`));
const inp = el('input'); inp.placeholder = 'розкодований текст (інструменти нижче)'; inp.style.width = '100%';
const b = el('button', 'primary', 'Надіслати');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value.trim() });
c.append(inp, b);
} else { // pin / md5
const inp = el('input'); inp.placeholder = z.kind === 'pin' ? 'PIN (4 цифри)' : 'пароль';
const b = el('button', 'primary', 'Спробувати');
b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value });
inp.onkeydown = e => { if (e.key === 'Enter') b.click(); };
c.append(inp, b);
}
c.append(hintBlock(z.hint));
pzList.append(c);
}
}
// ---------- Лабораторні міні-ігри ----------
const rng32 = seed => { let a = seed | 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
function drawPrint(ctx, seed, rot) {
const rr = rng32(seed), W = ctx.canvas.width, c = W / 2;
ctx.fillStyle = '#0d1116'; ctx.fillRect(0, 0, W, W);
ctx.save(); ctx.translate(c, c); ctx.rotate(rot || 0);
ctx.strokeStyle = '#9db4c8'; ctx.lineWidth = 1.4;
for (let i = 0; i < 11; i++) {
const rad = 4 + i * (c - 8) / 11 + rr() * 3, a0 = rr() * 6.28, len = 1.2 + rr() * 3.6;
ctx.beginPath(); ctx.arc(0, 0, rad, a0, a0 + len); ctx.stroke();
if (rr() < 0.4) { ctx.beginPath(); ctx.arc(0, 0, rad * 0.72, a0 + 2, a0 + 2.7); ctx.stroke(); }
}
ctx.restore();
}
const laneHtml = (dna, name) =>
`<div style="text-align:center;width:86px">
<div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto">
${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')}
</div><div class="small dim">${name}</div></div>`;
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
function pickCompare(title, go) {
const mm = openModal(`<h3>${esc(title)}</h3>
<p class="dim small">Оберіть до 6 профілів для порівняння зі зразком.</p>
<input id="pk-q" placeholder="фільтр за іменем…" style="width:100%">
<div id="pk-list" style="max-height:280px;overflow-y:auto;margin:8px 0"></div>
<button class="primary" id="pk-go">Порівняти</button> <span class="dim small" id="pk-n"></span>`);
const sel = new Set();
const list = mm.inner.querySelector('#pk-list'), q = mm.inner.querySelector('#pk-q'), n = mm.inner.querySelector('#pk-n');
const draw = () => {
list.innerHTML = '';
const f = q.value.trim().toLowerCase();
for (const c of App.init.citizens) {
if (c.id === App.state.victim) continue;
if (f && !c.name.toLowerCase().includes(f)) continue;
const lb = el('label', 'small'); lb.style.display = 'block';
const cb = el('input'); cb.type = 'checkbox'; cb.checked = sel.has(c.id);
cb.onchange = () => {
if (cb.checked && sel.size >= 6) { cb.checked = false; return; }
if (cb.checked) sel.add(c.id); else sel.delete(c.id);
n.textContent = `обрано: ${sel.size}/6`;
};
lb.append(cb, ` ${c.name}${c.home}, ${c.job}`);
list.append(lb);
}
};
q.oninput = draw; draw();
mm.inner.querySelector('#pk-go').onclick = () => {
if (!sel.size) return;
// Шортліст не викидаємо, а ховаємо: «← Назад» із гелю повертає до вже
// обраних шести профілів, а не до нуля.
mm.wrap.style.display = 'none';
go([...sel], {
back: () => { mm.wrap.style.display = ''; },
onClose: () => mm.wrap.remove()
});
};
}
function dnaModal(ids, sampleEv, sample, nav) {
if (!sample || !ids.length) return;
const mm = openModal(`<h3>Гель-електрофорез</h3>
<p class="dim small">Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.</p>
<div style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 6px">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="width:0;border-left:1px solid var(--line);height:180px"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`, nav);
mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.'));
mm.inner.onclick = e => {
const s = e.target.closest('[data-cid]');
if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.close(); }
};
}
function printModal(ids, nav) {
if (App.state.printSample == null || !ids.length) return;
const mm = openModal(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
<span class="dim small"> ← зразок</span>
<div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`, nav);
drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7);
const box = mm.inner.querySelector('#pcands');
for (const id of ids) {
const c = App.byId(id);
const w = el('div', '', ''); w.style.cursor = 'pointer'; w.style.textAlign = 'center';
const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px';
w.append(cv, el('div', 'small dim', esc(c.name)));
drawPrint(cv.getContext('2d'), c.printSeed, 0);
w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.close(); };
box.append(w);
}
mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));
}
function triangulateModal(data) {
const mm = openModal(`<h3>Тріангуляція номера ${esc(data.number)}</h3>
<p class="dim small">Три вежі, три відстані до апарата — станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.</p>
<canvas id="trcv"></canvas>
<div id="trreads" class="small" style="margin-top:6px"></div>`,
// Прилітає у відповідь на запит до оператора: два натискання «Тріангуляція»
// дали б дві мапи одна на одній.
{ key: 'triangulate' });
const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv';
fitCanvas(cv);
const ctx = cv.getContext('2d');
drawDistrict(ctx); // лишає трансформацію у світових координатах
for (const t of data.towers) {
ctx.strokeStyle = 'rgba(232,176,75,.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(t.x, t.y, t.dist, 0, 7); ctx.stroke();
ctx.fillStyle = '#e8b04b'; ctx.beginPath(); ctx.arc(t.x, t.y, 6, 0, 7); ctx.fill();
ctx.fillStyle = '#e8b04b'; ctx.font = '12px Georgia'; ctx.fillText('▲ ' + t.name, t.x + 8, t.y + 4);
}
mm.inner.querySelector('#trreads').innerHTML = data.towers.map(t => `${esc(t.name)}: ~${t.dist} м`).join(' · ');
mm.inner.append(hintBlock('Кожна дуга — коло можливих точок навколо вежі. Апарат там, де всі три кола накладаються на один будинок. Клацніть саме той будинок.'));
cv.onclick = e => {
const r = cv.getBoundingClientRect(), D = App.init.district.world;
const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height;
const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h);
if (b) { App.send({ t: 'triangulate_pick', building: b.id }); mm.wrap.remove(); }
};
}
App.on('mg', m => {
if (App.role !== 'analyst') return;
if (m.kind === 'triangulate') triangulateModal(m);
});
})();