2PlayerGame/chapter3/public/analyst.js
2026-07-23 18:22:22 +03:00

463 lines
28 KiB
JavaScript
Raw 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="filters">
<input id="q-name" placeholder="Ім'я Прізвище" style="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; }
// Розбиваємо введений запит на слова
const words = q.split(/\s+/);
// Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина
lastRows = App.init.citizens.filter(c => {
const nameWords = c.name.split(/\s+/);
return words.every(w => nameWords.some(nw => nw.startsWith(w)));
});
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="width:180px">
<button data-q="owner">Власник номера</button>
<button data-q="calls">Журнал дзвінків</button>
<button data-q="locate">Тріангуляція (де зараз)</button>
</div>
<div class="syshead">Банк</div>
<div class="filters">
<input id="bk-n" placeholder="Ім'я Прізвище або заклад" style="width:220px">
<button id="bk-go">Запросити виписку</button>
</div>
<div class="syshead">Реєстри</div>
<div class="filters">
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="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="width:200px">
<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></tr>');
for (const [cid, claim] of claims) {
const c = App.byId(cid);
const cams = App.init.district.cameras.filter(cam => cam.covers.includes(claim));
const cell = cams.length
? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('<br>')
: 'жодна камера туди не дивиться';
t.append(el('tr', '', `<td>${esc(c.name)}</td><td>${esc(bn(claim))}</td><td class="small">${cell}</td>`));
}
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);
const mk = (label, sample, doneId, noSampleMsg, run) => {
const b = el('button', '', label + (foundId(doneId) ? ' ✓' : ''));
b.disabled = foundId(doneId);
b.onclick = () => sample == null ? toast(noSampleMsg) : run();
labBox.append(b);
};
const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : '';
mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком',
App.state.dnaSample, 'lab_dna_match',
'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.',
() => pickCompare('Гель-електрофорез: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna', App.state.dnaSample)));
if (App.state.dnaSample2)
mk(`ДНК: ${short(App.state.dna2Label)}`, App.state.dnaSample2, 'lab_dna_match2',
'',
() => pickCompare('Другий зразок: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna2', App.state.dnaSample2)));
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="display:inline-block;text-align:center;margin:0 7px;vertical-align:top">
<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" style="max-width:86px">${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.remove(); go([...sel]);
};
}
function dnaModal(ids, sampleEv, sample) {
if (!sample || !ids.length) return;
const mm = openModal(`<h3>Гель-електрофорез</h3>
<p class="dim small">Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.</p>
<div style="overflow-x:auto;white-space:nowrap">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="display:inline-block;width:16px;border-left:1px solid var(--line);height:180px;vertical-align:top"></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>`);
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.wrap.remove(); }
};
}
function printModal(ids) {
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>`);
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.wrap.remove(); };
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);
});
})();