site: анализ данных — загрузка Excel/CSV файлов

This commit is contained in:
Zulfija 2026-09-01 10:04:09 +00:00
parent 8f2cfcfad7
commit 172f1148af
4 changed files with 1139 additions and 1035 deletions

View File

@ -1,4 +1,4 @@
<!-- vibe42-agents-version: v36-makexlsx-2026-07-31 -->
<!-- vibe42-agents-version: v37-ktai-ds-2026-09-01 -->
# Vibe42 — учебная песочница: сайты, боты и первые приложения
Workspace юзера `Zulfija`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение.
@ -23,7 +23,7 @@ Workspace юзера `Zulfija`. Это **учебная среда**, где о
| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт |
|----------------|---------------|--------|
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` |
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap — вёрстка на дизайн-системе KT AI из `design-system/`. | старт с `design.md`, публикация в `pages` |
| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже |
| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже |
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже |
@ -839,6 +839,10 @@ git push origin HEAD:pages
---
## 🎨 design.md
## 🎨 design.md + дизайн-система KT AI
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.
Рядом лежит `design.md` — **прочитай его перед первой строкой вёрстки**. В нём каркас `index.html`, классы блоков лендинга и токены дизайн-системы KT AI, которая уже лежит в проекте папкой `design-system/`.
Правило простое: **весь UI собирается из классов и токенов ДС**. Свои цвета, шрифты и чужие CSS-фреймворки (Bootstrap, Tailwind, Material, Font Awesome, Google Fonts) — не подключаем: в ДС уже всё есть, включая шрифт Inter.
Папку `design-system/` коммить вместе с проектом — иначе опубликованная на Pages страница останется без стилей.

249
design.md
View File

@ -1,132 +1,207 @@
<!-- vibe42-design-version: v2-recipes-2026-07-07 -->
# Design system — Vibe42 песочница
<!-- vibe42-design-version: v3-ktai-ds-2026-09-01 -->
# Дизайн-система KT AI — по ней собирается весь UI
Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться.
В папке проекта лежит `design-system/` — это **дизайн-система KT AI**
(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`).
Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга.
## Палитра
**Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу
из готовых классов ДС и наполнить её содержанием юзера.
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз:
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```bash
cp -r /srv/opencode/design-system/kt-ai design-system
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
---
На мобиле — h1 → 36px, h2 → 28px.
## 🔒 Три правила, которые нельзя нарушать
## Лейаут
1. **Цвета и размеры — только токенами** `var(--kt-ai-…)`. Сырой hex (`#0F1218`, `#fff`) в вёрстке
запрещён: он ломает светлую/тёмную тему.
2. **Никакого Bootstrap / Tailwind / Material / Font Awesome / Google Fonts.** Шрифт (Inter) уже
внутри ДС и работает офлайн. Всё, что нужно лендингу, в ДС уже есть.
3. **Свой `<style>` — только на мелочи**, которых нет в ДС (позиционирование, картинка-фон).
Не переопределяй кнопки, карточки и типографику.
- max-width контента: **1140px**
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- border-radius: `8px` (кнопки), `16px` (карточки)
---
## Стартер `index.html`
## Старт: `index.html`
Это рабочий каркас лендинга. Копируй его целиком и меняй содержание.
```html
<!DOCTYPE html>
<html lang="ru">
<html lang="ru" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<title>Название проекта</title>
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
<link rel="stylesheet" href="design-system/kt-ai-page.css">
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
/* ДС рисует кнопки для <button>; у ссылки-кнопки браузер добавляет подчёркивание */
a.kt-ai-btn { text-decoration: none; }
</style>
</head>
<body>
<section class="hero"><div class="container">
<h1>Заголовок</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div></section>
<section id="section" class="section"><div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div></section>
<header class="kt-ai-pagehead">
<div class="kt-ai-wrap kt-ai-pagehead-in">
<strong>Название</strong>
<span class="kt-ai-spacer"></span>
<a class="kt-ai-btn" data-variant="ghost" href="#about">О нас</a>
<a class="kt-ai-btn" data-variant="primary" href="#contacts">Оставить заявку</a>
</div>
</header>
<section class="kt-ai-hero" data-align="center">
<div class="kt-ai-wrap">
<h1>Главное обещание в одну строку</h1>
<p class="kt-ai-hero-sub">Подзаголовок: кому это и почему стоит остаться.</p>
<div class="kt-ai-hero-actions">
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#contacts">Оставить заявку</a>
<a class="kt-ai-btn" data-size="lg" href="#about">Подробнее</a>
</div>
</div>
</section>
<section id="about" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Что вы получите</h2>
<p>Одно-два предложения пояснения к секции.</p>
</div>
<div class="kt-ai-grid" style="margin-top:var(--kt-ai-space-9)">
<article class="kt-ai-tile"><h3>Пункт первый</h3><p>Коротко и по делу.</p></article>
<article class="kt-ai-tile"><h3>Пункт второй</h3><p>Коротко и по делу.</p></article>
<article class="kt-ai-tile"><h3>Пункт третий</h3><p>Коротко и по делу.</p></article>
</div>
</div>
</section>
<section id="contacts" class="kt-ai-cta">
<div class="kt-ai-wrap">
<h2>Призыв к действию</h2>
<p>Город, адрес, часы работы — то, что нужно, чтобы дойти.</p>
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#">Написать</a>
</div>
</section>
<footer class="kt-ai-wrap kt-ai-pagefoot">
<span>© 2026 Название</span>
<span class="kt-ai-spacer"></span>
<span>Контакты</span>
</footer>
</body>
</html>
```
## Что НЕ юзать
---
- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS достаточно
## Блоки страницы (`kt-ai-page.css`)
## Что МОЖНО (если очень надо)
| Класс | Что это |
|-------|---------|
| `.kt-ai-wrap` | контейнер по центру (1080px). `data-width="narrow"` — 720px, `"wide"` — 1200px |
| `.kt-ai-pagehead` + `.kt-ai-pagehead-in` | липкая шапка страницы |
| `.kt-ai-hero`, `.kt-ai-hero-sub`, `.kt-ai-hero-actions` | первый экран. `data-align="center"` — по центру |
| `.kt-ai-section` + `.kt-ai-section-head` (h2 + p) | обычная секция. `data-divider="true"` — линия сверху |
| `.kt-ai-grid` + `.kt-ai-tile` (h3 + p) | сетка плиток «возможности». `data-cols="2"/"4"`, у плитки `data-span="2"/"full"` |
| `.kt-ai-tile-icon` | иконка над заголовком плитки (спрайт — `design-system/icons/`) |
| `.kt-ai-cta` | финальный блок с призывом |
| `.kt-ai-pagefoot` | подвал |
| `.kt-ai-spacer` | распорка внутри шапки/подвала (толкает остальное вправо) |
| `.kt-ai-centered` | одна карточка по центру экрана — для страницы входа и 404 |
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>`
- **Placeholder картинки**: `https://picsum.photos/800/600`
- **Google Fonts через `<link>`** в head
Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно.
## 📦 Готовые рецепты (используй их, не изобретай)
## Элементы (`kt-ai-components.css`)
| Класс | Как применять |
|-------|---------------|
| `.kt-ai-btn` | кнопка / ссылка-кнопка. `data-variant="primary"` — главная, `"ghost"` — прозрачная, `"danger"` — опасная. `data-size="sm"/"lg"` |
| `.kt-ai-card` | карточка контента |
| `.kt-ai-chip` | статус-пилюля / тег |
| `.kt-ai-link` | текстовая ссылка |
| `.kt-ai-field` + `.kt-ai-input` / `.kt-ai-textarea` / `.kt-ai-select` | поле формы с подписью |
**Один `primary` на экран.** Остальные кнопки — обычные или `ghost`.
## Токены (`kt-ai-tokens.css`)
Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`,
`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`,
`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`.
Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`.
Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`.
Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`.
**Тема** — атрибут на `<html>`: `data-theme="auto"` (по системной, ставь его по умолчанию),
`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно.
---
## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат)
Тогда экран собирается **не вёрсткой руками, а конфигом**:
1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения
(без React и npm).
2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег
`<script id="kt-app-config" type="application/json">…</script>` внутри каркаса — он сам отрисует
таблицу, KPI, фильтры, drawer и темы.
3. Проверь конфиг: `python3 design-system/scripts/validate_product.py config.json --strict` → нужен `0/0`.
Полные правила для сложных экранов — `design-system/AGENTS.md`, список компонентов —
`design-system/COMPONENTS.md`, приёмка — `design-system/CHECKLIST.md`. Читай их, когда лендинга
уже мало.
---
## 📦 Готовые рецепты
### Фото без ключей
- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed.
- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу.
- Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600`
- По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL)
Вставляй с рамкой ДС: `<img src="…" alt="…" style="width:100%;border-radius:var(--kt-ai-radius-3xl)">`
### Кнопка WhatsApp
```html
<a href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!" style="display:inline-block;background:#25D366;color:#fff;padding:12px 22px;border-radius:8px;font-weight:700;text-decoration:none">Написать в WhatsApp</a>
<a class="kt-ai-btn" data-variant="primary" data-size="lg"
href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!">Написать в WhatsApp</a>
```
`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение.
`7XXXXXXXXXX` — номер в международном формате без `+`.
### Форма заявки без бэкенда
```html
<form action="https://formsubmit.co/EMAIL" method="POST">
<input name="name" placeholder="Имя" required>
<input name="phone" placeholder="Телефон" required>
<button type="submit">Отправить</button>
<form action="https://formsubmit.co/EMAIL" method="POST" style="max-width:420px">
<div class="kt-ai-field"><label>Имя</label><input class="kt-ai-input" name="name" required></div>
<div class="kt-ai-field"><label>Телефон</label><input class="kt-ai-input" name="phone" required></div>
<button class="kt-ai-btn" data-variant="primary" type="submit">Отправить</button>
</form>
```
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз).
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму).
### Google Fonts (максимум 2 шрифта: один display + один text)
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&family=Unbounded:wght@700&display=swap" rel="stylesheet">
```
В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста.
### Favicon-эмодзи (inline SVG data-URI, без файла)
### Favicon-эмодзи (без файла)
```html
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
```
Меняй эмодзи `☕` на подходящее теме.
### Карта (Google Maps embed, без ключа)
На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть:
### Карта
На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть:
```html
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320" style="border:0;border-radius:12px" loading="lazy"></iframe>
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320"
style="border:0;border-radius:var(--kt-ai-radius-3xl)" loading="lazy"></iframe>
```
### Плавные появления при скролле (IntersectionObserver)
### Плавные появления при скролле
```html
<style>
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
@ -139,4 +214,12 @@ var io=new IntersectionObserver(function(entries){
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
</script>
```
Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке.
---
## Перед тем как сказать «готово»
- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её).
- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов.
- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно).
- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum».

1193
index.html

File diff suppressed because it is too large Load Diff

668
script.js
View File

@ -1,258 +1,510 @@
document.addEventListener('DOMContentLoaded', function() {
const reveals = document.querySelectorAll('.reveal');
const faqItems = document.querySelectorAll('.faq-item');
const sizeBtns = document.querySelectorAll('.size-btn');
const contrastBtns = document.querySelectorAll('.contrast-btn');
const amountBtns = document.querySelectorAll('.amount-btn');
const customAmount = document.getElementById('custom-amount');
let workbook = null;
let worksheet = null;
let rawData = [];
let fileName = '';
function checkReveal() {
const triggerBottom = window.innerHeight * 0.85;
reveals.forEach(reveal => {
const boxTop = reveal.getBoundingClientRect().top;
if (boxTop < triggerBottom) {
reveal.classList.add('in');
}
});
}
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
const fileInfo = document.getElementById('file-info');
const file_name = document.getElementById('file-name');
const fileMeta = document.getElementById('file-meta');
const analyzeBtn = document.getElementById('analyze-btn');
const errorMessage = document.getElementById('error-message');
const loading = document.getElementById('loading');
const results = document.getElementById('results');
window.addEventListener('scroll', checkReveal);
checkReveal();
uploadArea.addEventListener('click', () => fileInput.click());
faqItems.forEach(item => {
const question = item.querySelector('.faq-question');
question.addEventListener('click', function() {
const isExpanded = this.getAttribute('aria-expanded') === 'true';
faqItems.forEach(i => {
i.classList.remove('active');
i.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
});
if (!isExpanded) {
item.classList.add('active');
this.setAttribute('aria-expanded', 'true');
}
});
});
sizeBtns.forEach(btn => {
btn.addEventListener('click', function() {
sizeBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.body.classList.remove('text-normal', 'text-large', 'text-xlarge');
document.body.classList.add('text-' + this.dataset.size);
});
});
contrastBtns.forEach(btn => {
btn.addEventListener('click', function() {
contrastBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.body.classList.remove('contrast-normal', 'contrast-high');
if (this.dataset.contrast === 'high') {
document.body.classList.add('contrast-high');
}
});
});
amountBtns.forEach(btn => {
btn.addEventListener('click', function() {
amountBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
if (customAmount) customAmount.value = '';
});
});
if (customAmount) {
customAmount.addEventListener('input', function() {
amountBtns.forEach(b => b.classList.remove('active'));
});
}
});
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.removeAttribute('hidden');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable) focusable.focus();
modal.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal(modalId);
}
if (e.key === 'Tab') {
const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
uploadArea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
fileInput.click();
}
});
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
first.focus();
}
}
});
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.setAttribute('hidden', '');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
}
let currentStep = 1;
const totalSteps = 10;
function showStep(step) {
const steps = document.querySelectorAll('.form-step:not(.result-step)');
steps.forEach((s, index) => {
s.classList.remove('active');
if (index === step - 1) {
s.classList.add('active');
}
});
currentStep = step;
}
function nextStep(current) {
const currentStepEl = document.querySelectorAll('.form-step:not(.result-step)')[current - 1];
const inputs = currentStepEl.querySelectorAll('input[type="radio"], input[type="checkbox"]');
let answered = false;
inputs.forEach(input => {
if (input.checked) answered = true;
uploadArea.classList.add('dragover');
});
if (!answered) {
alert('Пожалуйста, выберите вариант ответа');
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
const validTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'application/vnd.ms-excel.sheet.macroEnabled.12'
];
const ext = file.name.split('.').pop().toLowerCase();
const isValid = validTypes.includes(file.type) ||
['xlsx', 'xls', 'csv'].includes(ext);
if (!isValid) {
showError('Неподдерживаемый формат файла. Загрузите .xlsx, .xls или .csv');
return;
}
if (current < totalSteps) {
showStep(current + 1);
}
fileName = file.name;
file_name.textContent = '📄 ' + file.name;
fileMeta.textContent = 'Размер: ' + formatFileSize(file.size);
fileInfo.classList.add('visible');
uploadArea.classList.add('has-file');
analyzeBtn.disabled = false;
hideError();
results.classList.remove('visible');
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
workbook = XLSX.read(data, { type: 'array' });
if (workbook.SheetNames.length > 0) {
worksheet = workbook.Sheets[workbook.SheetNames[0]];
rawData = XLSX.utils.sheet_to_json(worksheet, { defval: '' });
if (rawData.length === 0) {
showError('Файл пуст или не содержит данных');
analyzeBtn.disabled = true;
return;
}
function prevStep(current) {
if (current > 1) {
showStep(current - 1);
analyzeBtn.disabled = false;
}
} catch (err) {
showError('Ошибка чтения файла: ' + err.message);
analyzeBtn.disabled = true;
}
};
reader.onerror = () => {
showError('Ошибка при чтении файла');
analyzeBtn.disabled = true;
};
reader.readAsArrayBuffer(file);
}
function submitTest() {
const resultsDiv = document.getElementById('test-results');
const answers = {
disability: document.querySelector('input[name="disability"]:checked'),
work: document.querySelector('input[name="work"]:checked'),
location: document.querySelector('input[name="location"]:checked'),
income: document.querySelector('input[name="income"]:checked'),
housing: document.querySelector('input[name="housing"]:checked'),
tsr: document.querySelectorAll('input[name="tsr"]:checked'),
employment: document.querySelector('input[name="employment"]:checked'),
children: document.querySelector('input[name="children"]:checked'),
legal: document.querySelector('input[name="legal"]:checked'),
volunteer: document.querySelector('input[name="volunteer"]:checked')
analyzeBtn.addEventListener('click', () => {
if (rawData.length === 0) {
showError('Нет данных для анализа');
return;
}
loading.classList.add('visible');
results.classList.remove('visible');
setTimeout(() => {
try {
performAnalysis();
loading.classList.remove('visible');
results.classList.add('visible');
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
loading.classList.remove('visible');
showError('Ошибка анализа: ' + err.message);
}
}, 500);
});
function performAnalysis() {
const columns = Object.keys(rawData[0]);
const stats = calculateStats(rawData, columns);
const insights = generateInsights(rawData, columns, stats);
renderStats(stats);
renderInsights(insights);
renderCharts(rawData, columns, stats);
renderTable(rawData, columns);
}
function calculateStats(data, columns) {
const stats = {
totalRows: data.length,
totalColumns: columns.length,
columns: columns,
emptyCells: 0,
numericColumns: [],
textColumns: [],
dateColumns: [],
columnStats: {}
};
let results = [];
columns.forEach(col => {
const values = data.map(row => row[col]).filter(v => v !== '' && v !== null && v !== undefined);
const emptyCount = data.length - values.length;
stats.emptyCells += emptyCount;
if (answers.disability && answers.disability.value !== 'none') {
results.push({
title: 'Пенсия по инвалидности',
desc: 'Положена всем гражданам с инвалидностью. Размер зависит от группы.'
const firstValue = values[0];
const isNumeric = values.every(v => !isNaN(parseFloat(v)) && isFinite(v));
const isDate = values.some(v => {
const d = new Date(v);
return !isNaN(d.getTime());
});
results.push({
title: 'Ежемесячная выплата (ЕДВ)',
desc: 'Дополнительная выплата для отдельных категорий.'
if (isNumeric) {
stats.numericColumns.push(col);
const numValues = values.map(v => parseFloat(v));
stats.columnStats[col] = {
type: 'numeric',
min: Math.min(...numValues),
max: Math.max(...numValues),
avg: numValues.reduce((a, b) => a + b, 0) / numValues.length,
sum: numValues.reduce((a, b) => a + b, 0),
empty: emptyCount
};
} else if (isDate) {
stats.dateColumns.push(col);
stats.columnStats[col] = {
type: 'date',
empty: emptyCount
};
} else {
stats.textColumns.push(col);
const unique = [...new Set(values)];
const counts = {};
values.forEach(v => { counts[v] = (counts[v] || 0) + 1; });
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
stats.columnStats[col] = {
type: 'text',
unique: unique.length,
top: sorted.slice(0, 5),
empty: emptyCount
};
}
});
return stats;
}
function generateInsights(data, columns, stats) {
const insights = [];
insights.push({
icon: '📊',
text: `<strong>Всего записей:</strong> ${stats.totalRows} в ${stats.totalColumns} колонках`
});
if (stats.numericColumns.length > 0) {
insights.push({
icon: '💰',
text: `<strong>Числовые колонки:</strong> ${stats.numericColumns.join(', ')}`
});
stats.numericColumns.forEach(col => {
const s = stats.columnStats[col];
if (s && s.type === 'numeric') {
insights.push({
icon: '📈',
text: `<strong>${col}:</strong> среднее = ${formatNumber(s.avg)}, мин = ${formatNumber(s.min)}, макс = ${formatNumber(s.max)}`
});
}
});
}
if (answers.income && answers.income.value === 'low') {
results.push({
title: 'Материальная помощь',
desc: 'Единовременная выплата в трудной жизненной ситуации.'
if (stats.textColumns.length > 0) {
insights.push({
icon: '🏷️',
text: `<strong>Текстовые колонки:</strong> ${stats.textColumns.join(', ')}`
});
stats.textColumns.forEach(col => {
const s = stats.columnStats[col];
if (s && s.type === 'text' && s.top.length > 0) {
const topList = s.top.map(item => item[0]).slice(0, 3).join(', ');
insights.push({
icon: '🔝',
text: `<strong>${col}:</strong> чаще всего встречается "${topList}"`
});
}
});
}
if (answers.housing && answers.housing.value === 'yes') {
results.push({
title: 'Субсидия на оплату ЖКУ',
desc: 'Компенсация до 50% расходов на коммунальные услуги.'
});
results.push({
title: 'Программа доступного жилья',
desc: 'Постановка в очередь на социальное жильё.'
const emptyPercent = ((stats.emptyCells / (stats.totalRows * stats.totalColumns)) * 100).toFixed(1);
if (emptyPercent > 10) {
insights.push({
icon: '⚠️',
text: `<strong>Внимание:</strong> ${emptyPercent}% пустых ячеек — проверьте полноту данных`
});
}
if (answers.tsr && answers.tsr.length > 0) {
results.push({
title: 'Технические средства реабилитации',
desc: 'Бесплатное получение колясок, протезов и других ТСР.'
return insights;
}
function renderStats(stats) {
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="stat-card">
<div class="stat-value">${stats.totalRows}</div>
<div class="stat-label">Всего записей</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalColumns}</div>
<div class="stat-label">Колонок</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.numericColumns.length}</div>
<div class="stat-label">Числовых полей</div>
</div>
<div class="stat-card">
<div class="stat-value">${formatNumber(stats.emptyCells)}</div>
<div class="stat-label">Пустых ячеек</div>
</div>
`;
}
function renderInsights(insights) {
const list = document.getElementById('insights-list');
list.innerHTML = insights.map(i => `
<div class="insight-item">
<div class="insight-icon">${i.icon}</div>
<div class="insight-text">${i.text}</div>
</div>
`).join('');
}
let charts = [];
function renderCharts(data, columns, stats) {
const container = document.getElementById('charts-section');
container.innerHTML = '';
charts.forEach(chart => chart.destroy());
charts = [];
if (stats.numericColumns.length >= 2) {
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Распределение числовых данных</h3>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const ctx = document.getElementById('barChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: stats.numericColumns.slice(0, 10),
datasets: [{
label: 'Среднее значение',
data: stats.numericColumns.slice(0, 10).map(col =>
stats.columnStats[col] ? stats.columnStats[col].avg : 0
),
backgroundColor: 'rgba(99, 102, 241, 0.6)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
charts.push(chart);
}
if (answers.employment && answers.employment.value === 'yes') {
results.push({
title: 'Содействие в трудоустройстве',
desc: 'Квоты для людей с инвалидностью, программы переобучения.'
if (stats.textColumns.length > 0 && stats.textColumns.some(col =>
stats.columnStats[col] && stats.columnStats[col].unique <= 10
)) {
const textCol = stats.textColumns.find(col =>
stats.columnStats[col] && stats.columnStats[col].unique <= 10
);
if (textCol) {
const s = stats.columnStats[textCol];
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Распределение: ${textCol}</h3>
<div class="chart-container">
<canvas id="pieChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const ctx = document.getElementById('pieChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'pie',
data: {
labels: s.top.map(item => item[0]),
datasets: [{
data: s.top.map(item => item[1]),
backgroundColor: [
'rgba(99, 102, 241, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(245, 158, 11, 0.8)',
'rgba(239, 68, 68, 0.8)',
'rgba(139, 92, 246, 0.8)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
charts.push(chart);
}
}
if (answers.volunteer && answers.volunteer.value !== 'no') {
results.push({
title: 'Волонтёрская поддержка',
desc: 'Помощь волонтёров в быту, сопровождении, транспорте.'
if (stats.numericColumns.length >= 1 && data.length <= 100) {
const chartCard = document.createElement('div');
chartCard.className = 'chart-card';
chartCard.innerHTML = `
<h3>Динамика данных</h3>
<div class="chart-container">
<canvas id="lineChart"></canvas>
</div>
`;
container.appendChild(chartCard);
const firstNumeric = stats.numericColumns[0];
const values = data.slice(0, 50).map((_, i) =>
data[i] && data[i][firstNumeric] ? parseFloat(data[i][firstNumeric]) : 0
);
const ctx = document.getElementById('lineChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: values.map((_, i) => i + 1),
datasets: [{
label: firstNumeric,
data: values,
borderColor: 'rgba(16, 185, 129, 1)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
},
scales: {
y: {
beginAtZero: false
}
}
}
});
charts.push(chart);
}
}
if (answers.children && answers.children.value === 'yes') {
results.push({
title: 'Пособие на детей',
desc: 'Дополнительные выплаты на детей в семье.'
function renderTable(data, columns) {
const table = document.getElementById('data-table');
const maxRows = Math.min(100, data.length);
let html = '<thead><tr>';
columns.forEach(col => {
html += '<th>' + escapeHtml(col) + '</th>';
});
}
html += '</tr></thead><tbody>';
if (answers.legal && answers.legal.value === 'yes') {
results.push({
title: 'Юридическая помощь',
desc: 'Бесплатные консультации юриста по вопросам инвалидности.'
for (let i = 0; i < maxRows; i++) {
html += '<tr>';
columns.forEach(col => {
html += '<td>' + escapeHtml(data[i][col]) + '</td>';
});
html += '</tr>';
}
if (results.length === 0) {
results.push({
title: 'Обратитесь в центр соцзащиты',
desc: 'Специалисты подберут индивидуальную программу поддержки.'
});
if (data.length > maxRows) {
html += '<tr><td colspan="' + columns.length + '" style="text-align:center;color:#718096;">';
html += '... показано 100 из ' + data.length + ' записей ...';
html += '</td></tr>';
}
resultsDiv.innerHTML = results.map(r =>
'<div class="result-item"><h4>' + r.title + '</h4><p>' + r.desc + '</p></div>'
).join('');
const steps = document.querySelectorAll('.form-step:not(.result-step)');
steps.forEach(s => s.classList.remove('active'));
document.querySelector('.result-step').classList.add('active');
html += '</tbody>';
table.innerHTML = html;
}
function resetTest() {
const form = document.getElementById('test-form');
form.reset();
showStep(1);
function exportToCSV() {
if (!rawData || rawData.length === 0) return;
const ws = XLSX.utils.json_to_sheet(rawData);
const csv = XLSX.utils.sheet_to_csv(ws);
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName.replace(/\.[^/.]+$/, '') + '_analysis.csv';
link.click();
}
if (window.location.hash) {
setTimeout(function() {
const element = document.querySelector(window.location.hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
function exportToExcel() {
if (!rawData || rawData.length === 0) return;
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(rawData);
XLSX.utils.book_append_sheet(wb, ws, 'Data');
XLSX.writeFile(wb, fileName.replace(/\.[^/.]+$/, '') + '_analysis.xlsx');
}
}, 100);
function showError(msg) {
errorMessage.textContent = msg;
errorMessage.classList.add('visible');
}
function hideError() {
errorMessage.classList.remove('visible');
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / 1048576).toFixed(1) + ' МБ';
}
function formatNumber(num) {
if (typeof num !== 'number') return num;
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return Math.round(num * 100) / 100;
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}