484 lines
30 KiB
JavaScript
484 lines
30 KiB
JavaScript
// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія.
|
||
'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="filters">
|
||
<input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
|
||
<button id="q-go" class="primary">Знайти</button>
|
||
</div>
|
||
<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');
|
||
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);
|
||
}
|
||
|
||
// ---------- Системи-форми ----------
|
||
// Кожен запит вводиться руками; результат — навіть дрібниця — лягає на дошку.
|
||
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>`);
|
||
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);
|
||
});
|
||
})();
|