2PlayerGame/chapter3/public/analyst.js

454 lines
27 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія.
'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().toLowerCase();
const hit = App.init.citizens.find(c => c.name.toLowerCase() === q);
if (!hit) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Перевірте написання.</p>'; return; }
lastRows = [hit]; 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);
});
})();