// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки. "use strict"; const App = (window.App = { ws: null, role: null, session: null, init: null, // {district, citizens, intro, options...} state: null, // останній снапшот від сервера handlers: {}, on(t, fn) { (this.handlers[t] ??= []).push(fn); }, send(m) { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(m)); }, byId(cid) { return this.init.citizens.find((c) => c.id === cid); }, }); const $ = (sel) => document.querySelector(sel); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; const esc = (s) => String(s).replace( /[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], ); function toast(txt) { const t = el("div", "toast", esc(txt)); $("#toasts").append(t); setTimeout(() => t.remove(), 4200); } // Підказки приховані за замовчуванням — спершу думаємо самі. function hintBlock(text) { const wrap = el("div", "hintwrap"); if (!text) return wrap; const btn = el("button", "small", "💡 Підказка"); const body = el("div", "small dim"); body.style.cssText = "display:none;margin-top:4px"; body.textContent = text; btn.onclick = () => { body.style.display = body.style.display === "none" ? "block" : "none"; }; wrap.append(btn, body); return wrap; } // ---------- Мережа ---------- function connect(code, role) { App.noReconnect = false; const ws = (App.ws = new WebSocket(`ws://${location.host}`)); ws.onopen = () => ws.send( JSON.stringify({ t: "join", session: code, role, pos: App.lastFieldPos }), ); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.t === "reset") { toast("Сейв видалено — справа починається з нуля."); setTimeout(() => location.reload(), 1400); return; } if (m.t === "joinerr") { // сесія заповнена по цій ролі — лишаємось у лобі, без автоперепідключення App.noReconnect = true; toast(m.txt); ws.close(); return; } if (m.t === "pos") App.fieldPositions = m.field || []; if (m.t === "toast") { if (m.txt.includes("підписав ордер") && window.Snd) Snd.stamp(); return toast(m.txt); } if (m.t === "init") { App.init = m; App.role = m.role; App.myNum = m.num; document.body.classList.add("role-" + m.role); $("#lobby").style.display = "none"; $("#app").classList.add("on"); $("#b-session").textContent = "справа: " + App.session; for (const fn of App.handlers.init) fn(m); showIntro(); return; } if (m.t === "state") { App.state = m; $("#b-field").classList.toggle("on", m.online.field > 0); $("#b-analyst").classList.toggle("on", m.online.analyst > 0); $("#b-field").textContent = "поле" + (m.online.field > 1 ? " ×" + m.online.field : ""); $("#b-analyst").textContent = "аналітик" + (m.online.analyst > 1 ? " ×" + m.online.analyst : ""); renderSide(); renderEnd(); for (const fn of App.handlers.state) fn(m); return; } for (const fn of App.handlers[m.t] || []) fn(m); }; ws.onclose = () => { if (App.noReconnect) return; toast("Зв'язок втрачено, перепідключення…"); setTimeout(() => connect(code, role), 1500); }; } // ---------- Лобі ---------- for (const b of document.querySelectorAll(".rolebtn")) { b.onclick = () => { const code = ($("#code").value.trim() || "DEFAULT").toUpperCase(); App.session = code; localStorage.setItem("ch3", JSON.stringify({ code, role: b.dataset.role })); if (window.Snd) Snd.start(b.dataset.role); connect(code, b.dataset.role); }; } try { const prev = JSON.parse(localStorage.getItem("ch3")); if (prev && prev.code) $("#code").value = prev.code; } catch {} // ---------- Бічна дошка ---------- let sideTab = "ev"; let showHidden = false; // згорнута секція «Приховані» на дні дошки let evFilter = ""; // текстовий фільтр по картках доказів let evTag = null; // активний чип-тег (null = всі) // Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, // інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі // (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. const noteDraft = {}; // Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було // «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку, // і кожен наступний broadcast знову садив фокус у той інпут — а обробник клавіш // у field.js свідомо ігнорує натискання в INPUT/TEXTAREA. Клавіатура мовчала, // візуально нічого не змінювалось. const FOCUS_GRACE_MS = 3000; let lastTypedAt = 0; let restoringFocus = false; // наш власний .focus() не має продовжувати вікно { const side = $("#side"); const touch = () => { if (!restoringFocus) lastTypedAt = Date.now(); }; // capture: фільтр дошки сам кличе renderSide() зі свого oninput, тож позначку // часу треба поставити ДО того, як обробник цілі встигне перемалювати панель. side.addEventListener("input", touch, true); side.addEventListener("focusin", touch, true); } function wireDraft(input, key) { input.dataset.draftKey = key; if (noteDraft[key]) input.value = noteDraft[key]; input.addEventListener("input", () => { noteDraft[key] = input.value; }); } // Які нотатки зараз відкриті на правку. Дошка перемальовується на кожен снапшот // стану, тож цей прапорець мусить жити ПОЗА рендером — інакше будь-яка дія // напарника згортала б поле просто під пальцями. const noteEditing = new Set(); // Рядок нотатки на дошці: текст, автор, ✎ і видалення. Один рендер на два місця — // спільні нотатки у вкладці й точкові на картці доказу, — щоб вони не розійшлися, // як свого часу розійшлися дві копії пошуку по базі. // compact — вигляд для картки доказу (рядок) проти вкладки «Нотатки» (картка); // draftKey — власний ключ чернетки, НЕ спільний із полем нової нотатки; // onSave(txt) / onDel() — хто саме шле повідомлення, вирішує викличне місце. // Правка йде на місці: сервер міняє лише txt, тож нотатка лишається там, де була. function noteRow(n, { compact, draftKey, onSave, onDel }) { const author = n.by === "field" ? "польовий" : "аналітик"; const row = el("div", compact ? "small dim" : "card"); if (noteEditing.has(n.id)) { const inp = el(compact ? "input" : "textarea", "small"); if (!compact) inp.rows = 2; inp.style.cssText = "width:100%"; inp.value = noteDraft[draftKey] ?? n.txt; wireDraft(inp, draftKey); const finish = () => { noteEditing.delete(n.id); delete noteDraft[draftKey]; }; const save = () => { const txt = inp.value.trim(); const changed = txt && txt !== n.txt; finish(); if (changed) onSave(txt); renderSide(); }; const cancel = () => { finish(); renderSide(); }; inp.onkeydown = (e) => { // У textarea Shift+Enter лишається переносом рядка — зберігає лише чистий Enter. if (e.key === "Enter" && (compact || !e.shiftKey)) { e.preventDefault(); save(); } // Esc скасовує правку і далі не йде: глобальний обробник інакше просто // зняв би фокус, і поле лишилось би відкритим без причини. if (e.key === "Escape") { e.stopPropagation(); cancel(); } }; const ok = el("button", "small", "Зберегти"); ok.onclick = save; const no = el("button", "small", "Скасувати"); no.onclick = cancel; row.append(inp, ok, no); return row; } if (compact) row.append(el("span", "", `✎ ${esc(n.txt)} `), el("span", "tag", author)); else row.append(el("div", "", esc(n.txt)), el("div", "small dim", author)); const ed = el("button", "small", "✎"); ed.title = "редагувати нотатку"; ed.onclick = () => { noteEditing.add(n.id); renderSide(); // Перемальовка щойно замінила вузол — фокус ставимо на новий, інакше // гравцеві довелося б клацати ще раз у поле, яке він сам щойно відкрив. const f = $(`#sidebody [data-draft-key="${CSS.escape(draftKey)}"]`); if (f) { f.focus(); f.setSelectionRange(f.value.length, f.value.length); } }; const del = el("button", "small", compact ? "×" : "видалити"); del.title = "видалити нотатку"; del.onclick = onDel; row.append(ed, del); return row; } $("#sidetabs").onclick = (e) => { const b = e.target.closest("button"); if (!b) return; sideTab = b.dataset.tab; for (const x of $("#sidetabs").children) x.classList.toggle("on", x === b); renderSide(); }; // Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало — // треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра // кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів, // легенд і тултипів ми не нав'язуємо. «Сховати» лишається в обох: на ньому // тримається секція «Приховані». const EV_MARKS = [ ["tick", "відмітити", null], ["cross", "викреслити", null], ["hide", "сховати", null], ]; // Порядок кольорів — це порядок кнопок. Замість синього тут фіолетовий: синій // (#5b8dbf) і зелений (#5aa870) майже збігалися за яскравістю, і на кольоровій // лівій грані рядка — смужці 4px — розрізнити їх було майже неможливо. const PEOPLE_MARKS = [ ["green", null, "var(--mk-green)"], ["red", null, "var(--mk-red)"], ["violet", null, "var(--mk-violet)"], ["amber", null, "var(--mk-amber)"], ["hide", "сховати", null], ]; const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" }; // Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори, // а колишній синій показуємо як фіолетовий, що прийшов йому на зміну. const LEGACY_PERSON_MARK = { tick: "green", cross: "red", blue: "violet" }; const isPersonKey = (key) => String(key).startsWith("c:"); function markOf(key) { const m = App.state.board.marks[key]; if (!m) return null; return isPersonKey(key) ? LEGACY_PERSON_MARK[m] || m : m; } function markButtons(key) { const cur = markOf(key); const mk = el("div", "mk"); for (const [kind, title, colour] of isPersonKey(key) ? PEOPLE_MARKS : EV_MARKS) { const b = el( "button", (colour ? "swatch " : "") + (cur === kind ? "on" : ""), colour ? `` : MARK_GLYPH[kind], ); if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями if (colour) b.style.color = colour; b.onclick = (ev) => { ev.stopPropagation(); App.send({ t: "mark", key, kind }); }; mk.append(b); } return mk; } function cardClasses(key) { const m = markOf(key); if (!m || m === "hide") return ""; return isPersonKey(key) ? " mk-" + m : " " + m; } // Свотч кольору для місць, де мітку лише показують (база польового). function markSwatch(kind) { const found = PEOPLE_MARKS.find(([k]) => k === kind); if (!found || !found[2]) return ""; return ``; } // Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій // секції «Приховані» — спершу розгортаємо її й перемальовуємо. function jumpToEvidence(id) { const find = () => document.querySelector(`#sidebody [data-ev="${CSS.escape(id)}"]`); let card = find(); if (!card && !showHidden) { showHidden = true; renderSide(); card = find(); } if (!card) return; card.scrollIntoView({ behavior: "smooth", block: "center" }); card.classList.add("flash"); setTimeout(() => card.classList.remove("flash"), 1300); } function renderSide() { if (!App.state) return; const body = $("#sidebody"); // запам'ятати, яке поле нотатки в фокусі, щоб повернути його після перемальовки const act = document.activeElement; const activeKey = act && act.dataset ? act.dataset.draftKey : null; const caret = activeKey ? act.selectionStart : null; body.innerHTML = ""; const marks = App.state.board.marks; if (sideTab === "ev") { const pins = App.state.board.pins; const found = App.state.found; const tagOf = (e) => e.tag || "запит"; body.append( el( "div", "dim small", "Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length, ), ); // Пошук + чипи-теги. До фіналу на дошці 30+ карток одним стовпчиком, і знайти // потрібну можна лише прокруткою. Чипи будуємо з тегів, що реально є на дошці, — // так жоден доказ не лишається поза жодним зрізом. const q = el("input", "small evfilter"); q.placeholder = "🔎 фільтр за текстом…"; q.value = evFilter; q.dataset.draftKey = "evfilter"; // щоб фокус/курсор пережили перемальовку q.oninput = () => { evFilter = q.value; renderSide(); }; body.append(q); const chips = el("div", "chips"); const allTags = [...new Set(found.map(tagOf))].sort((a, b) => a.localeCompare(b, "uk"), ); for (const t of [null, ...allTags]) { const b = el("button", "chip" + (evTag === t ? " on" : ""), t || "всі"); b.onclick = () => { evTag = evTag === t ? null : t; renderSide(); }; chips.append(b); } body.append(chips); // Фільтр застосовуємо ДО поділу на закріплені/решту, щоб 📌 не зникали з очей. const low = evFilter.trim().toLowerCase(); const passes = (e) => (!evTag || tagOf(e) === evTag) && (!low || `${e.title} ${e.detail} ${e.from || ""}`.toLowerCase().includes(low)); const shown = found.filter(passes); // Закріплені — зверху, від останнього запіненого до першого; далі решта доказів, // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.) const pinnedCards = [...pins] .reverse() .map((id) => shown.find((e) => e.id === id)) .filter(Boolean); const rest = [...shown].reverse().filter((e) => !pins.includes(e.id)); const buildCard = (e) => { const key = "e:" + e.id; const isPinned = pins.includes(e.id); const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : "")); c.dataset.ev = e.id; // якір для прокрутки-до-доказу const t = el("div", "t"); t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e)))); const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌"); pin.title = isPinned ? "відкріпити" : "закріпити для прокурора"; pin.onclick = () => App.send({ t: "pin", evId: e.id }); t.append(pin); c.append(t); // Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови. if (e.from) c.append(el("div", "small dim orig", esc(e.from))); c.append(el("div", "small", esc(e.detail))); // Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка). if (e.ref) { const src = found.find((x) => x.id === e.ref); if (src) { const link = el("button", "evref small", `↳ аналіз доказу: «${esc(src.title)}»`); link.onclick = () => jumpToEvidence(e.ref); c.append(link); } } c.append(markButtons(key)); const notes = (App.state.board.enotes || {})[e.id] || []; for (const n of notes) c.append( noteRow(n, { compact: true, draftKey: "edit:" + n.id, onSave: (txt) => App.send({ t: "enote_edit", evId: e.id, id: n.id, txt }), onDel: () => App.send({ t: "enote_del", evId: e.id, id: n.id }), }), ); const ni = el("input", "small"); ni.placeholder = "нотатка до доказу…"; ni.style.cssText = "width:100%;margin-top:4px"; wireDraft(ni, "e:" + e.id); ni.onkeydown = (ev) => { if (ev.key === "Enter" && ni.value.trim()) { App.send({ t: "enote", evId: e.id, txt: ni.value }); ni.value = ""; delete noteDraft["e:" + e.id]; } }; c.append(ni); return c; }; const hidden = []; const visible = (arr) => arr.filter((e) => { if (marks["e:" + e.id] === "hide") { hidden.push(e); return false; } return true; }); const pinnedVisible = visible(pinnedCards); const restVisible = visible(rest); for (const e of pinnedVisible) body.append(buildCard(e)); // Межа теки: видно, де закінчується те, що піде прокуророві. if (pinnedVisible.length && restVisible.length) body.append(el("div", "divider small dim", "— решта доказів —")); for (const e of restVisible) body.append(buildCard(e)); if (!pinnedVisible.length && !restVisible.length && !hidden.length) body.append( el( "div", "dim small", found.length ? "За цим фільтром нічого немає." : "Дошка порожня — знахідки з'являться тут.", ), ); // Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все») if (hidden.length) { const head = el( "button", "small", `${showHidden ? "▾" : "▸"} Приховані (${hidden.length})`, ); head.style.cssText = "width:100%;margin-top:10px;text-align:left"; head.onclick = () => { showHidden = !showHidden; renderSide(); }; body.append(head); if (showHidden) for (const e of hidden) body.append(buildCard(e)); } } if (sideTab === "notes") { const ta = el("textarea", "", ""); ta.rows = 2; ta.style.width = "100%"; ta.placeholder = "спільна нотатка…"; wireDraft(ta, "shared"); const add = el("button", "primary", "Додати"); add.onclick = () => { if (ta.value.trim()) { App.send({ t: "note", txt: ta.value }); ta.value = ""; delete noteDraft["shared"]; } }; body.append(ta, add); for (const n of [...App.state.board.notes].reverse()) body.append( noteRow(n, { compact: false, draftKey: "edit:" + n.id, onSave: (txt) => App.send({ t: "note_edit", id: n.id, txt }), onDel: () => App.send({ t: "note_del", id: n.id }), }), ); } // Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише // якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру // польового назавжди. if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) { const again = body.querySelector(`[data-draft-key="${activeKey}"]`); if (again) { restoringFocus = true; again.focus(); if (caret != null) try { again.setSelectionRange(caret, caret); } catch {} restoringFocus = false; } } } // ---------- Досьє ---------- function showDossier(c) { const dnaCells = c.dna .map( (v) => ``, ) .join(""); const m = openModal(`
| Вік / стать | ${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.) |
|---|---|
| Адреса | ${esc(c.home)} |
| Робота | ${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""} |
| Телефон | ${esc(c.phone)} |
| Зріст / взуття | ${c.height} см / ${c.shoe} розмір |
| Кров / відбитки | ${esc(c.blood)} / ${esc(c.printType)} |
| Верхній одяг | ${esc(c.coat)} |
| Звички | ${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""} |
| ДНК-профіль | ${dnaCells} |