Связи (pixel-aquarium, 10.06): партия 1 (полиш) + автопроверки графа
Усиления (движок-полиш) с детерминированной самопроверкой: - Веер детей — полукругом «наружу» (DEEP_FAN, по sibIndex от направления деда→родитель): не перекрывает нить-крошку и родителя; равномерное распределение. - LOD с гистерезисом (LOD_ZOOM_UP=1.6 / DOWN=1.4) — точки 3-го уровня ↔ аватарки без «мигания» у порога. - Двойной тап по пустому фону и сильный pinch-out на минимальном зуме = быстрый выход из погружения. - Префетч аватарок детей при наведении/нырке (prefetchChildren) — лица в кэше до раскрытия. Автопроверки (dev-only, ТОЛЬКО при ?fgtest): - js/pages/network/selftest.js — 14 ассертов: камера-центровка, collision (нет слипания), полукруг, spotlight (путь 1.0 / фон 0.25 / сброс при переключении / 100% на выходе), LOD, возврат зума. - Движок: read-only graph.debugState() + graph.pumpForTest() (синхронно докручивает кадры до покоя, не зависит от троттлинга rAF в фоне). Граф как window.__fg — тоже только при ?fgtest. - Прогон: 14/14 PASS (offset 0px, мин.дистанция детей 89px, веер ±99°, LOD 4/4). В обычной работе тест-хелперы не активны. Реальный путь /network-view не затронут. Бамп client → 1.2.148. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3012f0799b
commit
9a49cc67f0
@ -1,2 +1,2 @@
|
||||
client.version=1.2.147
|
||||
server.version=1.2.131
|
||||
client.version=1.2.148
|
||||
server.version=1.2.132
|
||||
|
||||
@ -132,6 +132,16 @@
|
||||
уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки.
|
||||
- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL.
|
||||
|
||||
**Полиш (партия 1):** веер детей раскрывается **полукругом «наружу»** (от пути назад, `DEEP_FAN`,
|
||||
по `sibIndex`) — не перекрывает нить-крошку; **LOD с гистерезисом** (`LOD_ZOOM_UP=1.6`/`DOWN=1.4` — без
|
||||
мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход;
|
||||
**префетч аватарок** детей при наведении/нырке.
|
||||
|
||||
**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`,
|
||||
прогоняет 14 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/выход) через детерминированные
|
||||
dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры до покоя — не
|
||||
зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны.
|
||||
|
||||
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.
|
||||
> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2
|
||||
> (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`.
|
||||
|
||||
@ -74,7 +74,11 @@ const DIVE_PATH_MUL = 0.72; // предки на пути назад — ч
|
||||
const DIVE_ROOT_MUL = 0.55; // корень (Иван) уходит вглубь сильнее всех
|
||||
const DIVE_OFFPATH_MUL = 0.55; // боковые ветки (вне пути) — уменьшаются на задний план
|
||||
const DIVE_BLUR = 3; // размытие фоновых (вне пути) узлов — эффект расфокуса/глубины, px
|
||||
const LOD_ZOOM = 1.55; // порог зума, на котором точки 3-го уровня превращаются в аватарки
|
||||
// LOD с гистерезисом (без «мигания» у порога): апгрейд точка→аватарка на UP, откат на DOWN (зазор).
|
||||
const LOD_ZOOM_UP = 1.6; // зум, на котором точки 3-го уровня превращаются в аватарки
|
||||
const LOD_ZOOM_DOWN = 1.4; // зум, ниже которого аватарки сворачиваются обратно в точки
|
||||
const DEEP_FAN = Math.PI * 1.1; // ширина веера детей: полукруг «наружу» от пути назад (~198°)
|
||||
const DOUBLE_TAP_MS = 320; // окно двойного тапа по фону (быстрый сброс погружения)
|
||||
|
||||
const RELATION_COLORS = {
|
||||
family: 'rgba(255, 159, 94, 0.92)',
|
||||
@ -172,8 +176,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const rebuildIndex = () => {
|
||||
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||
hasDeep = nodes.some((n) => n.tier >= 2);
|
||||
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
|
||||
childCountByParent = new Map();
|
||||
for (const n of nodes) { if (n.tier >= 2 && n.parentId) childCountByParent.set(n.parentId, (childCountByParent.get(n.parentId) || 0) + 1); }
|
||||
for (const n of nodes) {
|
||||
if (n.tier >= 2 && n.parentId) {
|
||||
const i = childCountByParent.get(n.parentId) || 0;
|
||||
n.sibIndex = i;
|
||||
childCountByParent.set(n.parentId, i + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
|
||||
@ -521,10 +532,18 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
|
||||
const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
|
||||
const cnt = childCountByParent.get(n.parentId) || 1;
|
||||
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: круговой «полукруг/орбита» без наложений
|
||||
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
|
||||
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
|
||||
n.x = p.x + Math.cos(n.deepAngle) * r;
|
||||
n.y = p.y + Math.sin(n.deepAngle) * r;
|
||||
// ВЕЕР «полукругом наружу»: раскрываем детей в сторону ОТ пути назад (от деда к родителю),
|
||||
// чтобы они не перекрывали нить-крошку и родителя. Равномерно по индексу среди братьев.
|
||||
const gp = nodeById.get(p.parentId);
|
||||
const ox = p.x - (gp ? gp.x : 0);
|
||||
const oy = p.y - (gp ? gp.y : 0);
|
||||
const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол)
|
||||
const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0; // -0.5..0.5 по веера
|
||||
const ang = outward + t * DEEP_FAN;
|
||||
n.x = p.x + Math.cos(ang) * r;
|
||||
n.y = p.y + Math.sin(ang) * r;
|
||||
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
|
||||
// tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42)
|
||||
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
|
||||
@ -573,11 +592,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая.
|
||||
function updateLod() {
|
||||
if (!hasDeep) return;
|
||||
const wantFullGlobal = zoom >= LOD_ZOOM;
|
||||
for (const n of nodes) {
|
||||
if (n.tier < 3) continue; // LOD касается только микрозвёзд 3-го уровня
|
||||
const wantFull = wantFullGlobal && (n.opacity > 0.04); // апгрейдим только видимые
|
||||
if (wantFull === (n.lod === 'full')) continue;
|
||||
const isFull = n.lod === 'full';
|
||||
// гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания)
|
||||
const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP;
|
||||
const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые
|
||||
if (wantFull === isFull) continue;
|
||||
setNodeLod(n, wantFull);
|
||||
}
|
||||
}
|
||||
@ -1085,9 +1106,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let pinching = false; // активен щипок (pan/tap на это время заморожены)
|
||||
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
|
||||
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
|
||||
let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс)
|
||||
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
|
||||
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
|
||||
|
||||
// Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера.
|
||||
const prefetched = new Set();
|
||||
function prefetchChildren(node) {
|
||||
if (!node) return;
|
||||
const pid = String(node.id);
|
||||
for (const m of nodes) {
|
||||
if (String(m.parentId) === pid && m.photo && !prefetched.has(m.photo)) {
|
||||
prefetched.add(m.photo);
|
||||
try { const im = new Image(); im.decoding = 'async'; im.src = m.photo; } catch { /* нет Image — не критично */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см.
|
||||
// advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение).
|
||||
// Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся
|
||||
@ -1114,6 +1149,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const want = n === target;
|
||||
if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; }
|
||||
}
|
||||
if (target) prefetchChildren(target); // подгружаем лица детей заранее (до клика)
|
||||
if (changed) wake();
|
||||
}
|
||||
// Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток + всплываем из погружения (тап по Ивану).
|
||||
@ -1140,6 +1176,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
diveZoom = DIVE_ZOOM;
|
||||
surfacing = false;
|
||||
camTargetX = null; camTargetY = null; // dive-камера центрирует сама
|
||||
prefetchChildren(n); // подгружаем лица детей заранее
|
||||
haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс
|
||||
wake();
|
||||
}
|
||||
@ -1224,8 +1261,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const mx = (pts[0].x + pts[1].x) / 2 - rect.left;
|
||||
const my = (pts[0].y + pts[1].y) / 2 - rect.top;
|
||||
const prevDist = pinchDist0;
|
||||
setZoom(zoom * (dist / pinchDist0), mx, my);
|
||||
pinchDist0 = dist;
|
||||
// сильный pinch-out (пальцы сходятся) на минимальном зуме во время погружения = всплыть назад
|
||||
if (diveTargetId && dist < prevDist && zoom <= ZOOM_MIN + 0.02) exitDive();
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
@ -1283,7 +1323,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const tapNode = downNodeEl
|
||||
? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId))
|
||||
: null;
|
||||
if (!tapNode) return;
|
||||
if (!tapNode) {
|
||||
// тап по пустому фону: двойной быстрый тап = сброс погружения/раскрытия (всплыть на весь граф)
|
||||
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0;
|
||||
if (now && now - lastBgTapTs < DOUBLE_TAP_MS) { lastBgTapTs = 0; if (diveTargetId || spotActive) collapseAll(); }
|
||||
else lastBgTapTs = now;
|
||||
return;
|
||||
}
|
||||
if (tapNode.isFocus) {
|
||||
if (typeof onCenterTap === 'function') onCenterTap(tapNode);
|
||||
return;
|
||||
@ -1493,6 +1539,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
exitDive, // Smart Zoom: всплыть из погружения на уровень назад
|
||||
collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню)
|
||||
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
|
||||
// --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) -------------------
|
||||
// Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера.
|
||||
debugState: () => ({
|
||||
zoom: +zoom.toFixed(3), camX: Math.round(camX), camY: Math.round(camY), diveTargetId, surfacing, spotActive, focusId,
|
||||
nodes: nodes.map((n) => ({ id: String(n.id), tier: n.tier, lod: n.lod, pinned: !!n.pinned, hovered: !!n.hovered, sibIndex: n.sibIndex, expandP: +(n.expandP || 0).toFixed(3), x: Math.round(n.x), y: Math.round(n.y), scale: +(n.scale || 0).toFixed(3), depthScale: +(n.depthScale || 1).toFixed(3), depthBlur: +(n.depthBlur || 0).toFixed(2), opacity: +(n.opacity || 0).toFixed(3), spotCur: +(n.spotCur || 1).toFixed(3) })),
|
||||
}),
|
||||
// Детерминированно докрутить анимацию до покоя (обходит троттлинг rAF в фоновых вкладках/тестах).
|
||||
pumpForTest: (maxFrames = 1200) => {
|
||||
let ts = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0;
|
||||
let i = 0;
|
||||
for (; i < maxFrames; i += 1) { ts += 16; tick(ts); if (!rafId) break; } // tick заморозился → rafId=0
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||
return i + 1;
|
||||
},
|
||||
destroy() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
|
||||
@ -256,6 +256,12 @@ export function renderNetworkLab({ navigate }) {
|
||||
filterBar.append(deepChip);
|
||||
stage.append(filterBar);
|
||||
|
||||
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
|
||||
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
|
||||
window.__fg = graph;
|
||||
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
|
||||
}
|
||||
|
||||
screen.cleanup = () => {
|
||||
graph.destroy();
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
|
||||
105
shine-UI/js/pages/network/selftest.js
Normal file
105
shine-UI/js/pages/network/selftest.js
Normal file
@ -0,0 +1,105 @@
|
||||
// Автопроверки интерактивного графа связей (dev-only).
|
||||
//
|
||||
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
|
||||
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
|
||||
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
|
||||
//
|
||||
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
|
||||
|
||||
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
|
||||
|
||||
export async function runNetworkSelfTest(graph, deepChipEl) {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const results = [];
|
||||
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
|
||||
const st = () => graph.debugState();
|
||||
|
||||
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
|
||||
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
|
||||
await wait(1700);
|
||||
|
||||
let s = st();
|
||||
const focusId = s.focusId;
|
||||
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
|
||||
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
|
||||
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
|
||||
|
||||
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
|
||||
graph.diveTo({ id: parent.id });
|
||||
const framesA = graph.pumpForTest();
|
||||
s = st();
|
||||
const p = s.nodes.find((n) => n.id === parent.id);
|
||||
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
|
||||
|
||||
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
|
||||
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
|
||||
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
|
||||
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
|
||||
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
|
||||
|
||||
// расталкивание: дети не слипаются
|
||||
let minD = Infinity;
|
||||
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
|
||||
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
|
||||
}
|
||||
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
|
||||
|
||||
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
|
||||
const outward = Math.atan2(p.y, p.x);
|
||||
const maxDev = kids.reduce((mx, k) => {
|
||||
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
|
||||
if (d > Math.PI) d = 2 * Math.PI - d;
|
||||
return Math.max(mx, d * 180 / Math.PI);
|
||||
}, 0);
|
||||
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
|
||||
|
||||
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
|
||||
const offPath = tier1.filter((n) => n.id !== parent.id);
|
||||
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
|
||||
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
|
||||
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
|
||||
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
|
||||
|
||||
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
|
||||
if (offPath.length) {
|
||||
graph.diveTo({ id: offPath[0].id });
|
||||
graph.pumpForTest();
|
||||
s = st();
|
||||
const prev = s.nodes.find((n) => n.id === parent.id);
|
||||
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
|
||||
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
|
||||
}
|
||||
|
||||
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
|
||||
const t2withKids = st().nodes.find((n) => n.tier === 2);
|
||||
if (t2withKids) {
|
||||
graph.diveTo({ id: t2withKids.id });
|
||||
graph.pumpForTest();
|
||||
s = st();
|
||||
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
|
||||
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
|
||||
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
|
||||
}
|
||||
|
||||
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
|
||||
graph.exitDive();
|
||||
graph.pumpForTest();
|
||||
s = st();
|
||||
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
|
||||
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
|
||||
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
|
||||
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
|
||||
|
||||
return finish(results);
|
||||
}
|
||||
|
||||
function finish(results) {
|
||||
const pass = results.filter((r) => r.pass).length;
|
||||
const out = { pass, total: results.length, results };
|
||||
if (typeof window !== 'undefined') window.__fgTestResults = out;
|
||||
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
|
||||
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`));
|
||||
return out;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user