Законченный MVP

This commit is contained in:
sbb45
2026-05-28 21:03:35 +05:00
parent a566bab1ae
commit 55583e2101
30 changed files with 1883 additions and 143 deletions
+298 -24
View File
@@ -1,42 +1,316 @@
# sv # Flamy Trade — Frontend
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). Публичный лендинг и дашборд ML-прогнозов для крипторынка. Построен на **SvelteKit 5** (Runes API), **Tailwind v4**, **TypeScript** и **lightweight-charts v5**.
## Creating a project ---
If you're seeing this, you've probably already done this step. Congrats! ## Стек
```sh | Слой | Технология |
# create a new project |---|---|
npx sv create my-app | Фреймворк | SvelteKit 5 (runes, SSR) |
| Язык | TypeScript (strict) |
| Стили | Tailwind CSS v4 (Vite plugin, `@theme`) |
| Графики | lightweight-charts v5 (TradingView) |
| Сервер | `@sveltejs/adapter-node` (Node.js) |
| Пакетный менеджер | pnpm |
---
## Быстрый старт
**Требования:** Node.js ≥ 20, pnpm ≥ 9
```bash
# 1. Установить зависимости
pnpm install
# 2. Запустить dev-сервер
pnpm dev
# 3. Открыть в браузере
# http://localhost:5173
``` ```
To recreate this project with the same configuration: Остальные команды:
```sh ```bash
# recreate this project pnpm build # Сборка для продакшена (папка build/)
pnpm dlx sv@0.15.3 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:none" sveltekit-adapter="adapter:node" --install pnpm Flamy-Trade_svelte pnpm preview # Превью продакшен-сборки
pnpm check # Svelte + TypeScript проверка типов
pnpm lint # ESLint + Prettier проверка
pnpm format # Автоформатирование
``` ```
## Developing ---
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ## Структура проекта
```sh ```
npm run dev src/
├── lib/
# or start the server and open the app in a new browser tab │ ├── components/
npm run dev -- --open │ │ ├── Chart.svelte # lightweight-charts обёртка
│ │ ├── home/
│ │ │ ├── DashboardMockup.svelte # 3D-макет на главной
│ │ │ └── DashboardStatus.svelte
│ │ ├── layout/
│ │ │ ├── Header.svelte # Фиксированная шапка + мобильное меню
│ │ │ └── Footer.svelte
│ │ └── ui/
│ │ ├── Button.svelte # Универсальная кнопка/ссылка
│ │ └── Logo.svelte
│ ├── blog/
│ │ └── posts.ts # Статьи блога (статические данные)
│ ├── stores/
│ │ └── chartStore.ts # Типы + генератор фейковых данных
│ ├── utils.ts # cn() — утилита для className
│ └── index.ts
├── routes/
│ ├── +layout.svelte # Корневой layout: Header, Footer, шрифты
│ ├── +page.svelte # Главная страница (секции)
│ ├── +error.svelte # Страница ошибки
│ ├── (sections)/ # Секции главной страницы
│ │ ├── Hero.svelte
│ │ ├── Ticker.svelte
│ │ ├── About.svelte
│ │ ├── Features.svelte
│ │ ├── Stats.svelte
│ │ ├── BlogPreview.svelte
│ │ └── Cta.svelte
│ ├── about/
│ │ ├── +page.svelte
│ │ └── _data.ts
│ ├── blog/
│ │ ├── +page.svelte # Список статей
│ │ └── [slug]/
│ │ ├── +page.ts # Загрузка статьи по slug
│ │ └── +page.svelte # Статья
│ └── dashboard/
│ └── +page.svelte # Интерактивный дашборд с графиком
└── routes/layout.css # Tailwind @theme + глобальные стили
``` ```
## Building ---
To create a production version of your app: ## Дизайн-система
```sh ### Цвета (`layout.css`)
npm run build
```css
--color-primary: #fe4b07 /* Акцент — оранжевый */
--color-primary-h:#c83e06 /* Hover-состояние */
--color-bg: #09080a /* Фон страницы */
--color-bg-e: #0f0e10 /* Elevated (шапка, карточки) */
--color-bg-c: #141318 /* Card background */
--color-bg-h: #1a191e /* Hover / бордеры */
--color-title: #f0ede6 /* Основной текст */
--color-desc: #8a887f /* Вторичный текст */
``` ```
You can preview the production build with `npm run preview`. В Tailwind используются как `bg-primary`, `text-desc`, `border-bg-h` и т.д.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ### Шрифты
| Переменная | Семейство | Применение |
|---|---|---|
| `font-display` | Unbounded | Заголовки, кнопки, тикеры |
| `font-sans` | DM Sans | Основной текст |
| `font-mono` | JetBrains Mono | Цены, метки, метаданные |
Подключаются через Google Fonts в `+layout.svelte`.
### Компонент `Button.svelte`
```svelte
<!-- Первичная (ссылка) -->
<Button href="/dashboard">Открыть дашборд</Button>
<!-- Вторичная (кнопка) -->
<Button variant="secondary" onclick={handler}>Действие</Button>
```
Props: `href?`, `target?`, `variant?: 'primary' | 'secondary'`, `class?`
---
## Дашборд и Charts
### Архитектура данных
```
chartStore.ts → generateChartData(symbolId, timeframeId)
└── возвращает ChartData
├── candles: CandleBar[] // 160 свечей
├── prediction: PredPoint[] // 19 прогнозных точек
├── signal: Signal // direction, confidence, entry/tp/sl
├── currentPrice: number
└── change24h(Pct): number
```
`generateChartData`**заглушка с детерминированными фейковыми данными**. При подключении реального бекенда её нужно заменить на API-запрос, сохранив возвращаемые типы:
```typescript
// chartStore.ts — заменить эту функцию на реальный запрос:
export async function fetchChartData(symbolId: string, tfId: TimeframeId): Promise<ChartData> {
const res = await fetch(`/api/chart/${symbolId}?tf=${tfId}`);
return res.json();
}
```
### Компонент `Chart.svelte`
```svelte
<Chart data={chartData} decimals={2} height={520} />
```
| Prop | Тип | Описание |
|---|---|---|
| `data` | `ChartData` | Свечи + прогноз + сигнал |
| `decimals` | `number` | Кол-во знаков после запятой для цены |
| `height` | `number` | Высота canvas в пикселях (default: 520) |
Что рендерится:
- **CandlestickSeries** — исторические зелёные/красные свечи
- **LineSeries** — пунктирная оранжевая линия ML-прогноза (19 свечей вперёд)
- **PriceLine** × 3 — горизонтальные линии Entry / TP / SL с подписями на шкале
- **OHLC тултип** — кастомный HTML-оверлей при наведении (показывает O/H/L/C и ML-значение)
- **ResizeObserver** — автоматически подстраивает ширину и высоту при изменении окна
### Таймфреймы и символы
Определены в `chartStore.ts`:
```typescript
SYMBOLS // 8 монет: BTC ETH SOL BNB XRP ADA DOGE LINK
TIMEFRAMES // 6 таймфреймов: 1m 5m 15m 1h 4h 1d
```
---
## Контракт API (для бекенда)
Дашборд ожидает от бекенда следующую форму ответа:
```typescript
type ChartData = {
candles: Array<{
time: number; // Unix timestamp (UTC seconds)
open: number;
high: number;
low: number;
close: number;
}>;
prediction: Array<{
time: number; // Unix timestamp будущих свечей
value: number; // Прогнозная цена (mid/close)
}>;
signal: {
direction: 'long' | 'short';
confidence: number; // 0100
entry: number;
tp: number;
sl: number;
};
currentPrice: number;
change24h: number;
change24hPct: number;
};
```
Эндпоинт (предполагаемый):
```
GET /api/chart/:symbol?tf=5m
```
---
## Блог
Статьи хранятся в `src/lib/blog/posts.ts` как статический массив `Post[]`.
```typescript
type Post = {
id: number;
slug: string; // URL: /blog/:slug
date: string; // ISO 8601
title: string;
description: string;
tags: string[];
content: string; // HTML-строка
};
```
Для подключения CMS — заменить `posts` на API-запрос в `+page.ts` / `+page.server.ts`.
---
## Конфигурация
### SvelteKit (`svelte.config.js`)
- Адаптер: `adapter-node` (деплой как Node.js-сервер)
- Runes: принудительно включены для всех файлов вне `node_modules`
### Tailwind v4 (`layout.css`)
Tailwind подключается через Vite-плагин (`@tailwindcss/vite`), конфиг CSS-первый — все кастомные токены в блоке `@theme` в `src/routes/layout.css`.
**Добавить новый токен:**
```css
/* layout.css */
@theme {
--color-accent: #your-color;
}
```
После этого доступен как `bg-accent`, `text-accent` и т.д.
---
## Деплой
Сборка производит Node.js-сервер:
```bash
pnpm build
node build/index.js
```
Переменные окружения:
```
PORT=3000 # порт (default: 3000)
HOST=0.0.0.0 # хост
ORIGIN=https://... # обязательно при deploy за проксей
```
Docker (минимальный `Dockerfile`):
```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY build/ ./build/
COPY package.json .
RUN npm install --omit=dev --ignore-scripts
CMD ["node", "build/index.js"]
```
---
## Соглашения по коду
- **Svelte 5 Runes** везде: `$state`, `$derived`, `$effect`, `$props` — никакого legacy API
- **Нет `export let`** — только деструктуризация `$props()`
- **Tailwind-first** — inline-стили только там, где Tailwind не справляется (динамические значения, chart dimensions)
- **cn()** из `$lib/utils` для условного объединения классов
- **Нет комментариев** если смысл очевиден из имён; комментарий — только для неочевидного инварианта
- **SSR-safe** — всё что требует `window`/`document` — только в `onMount`
- **Cleanup** — каждый `onMount` возвращает функцию очистки (removeEventListener, disconnect, remove)
---
## Известные ограничения
- Данные в дашборде — **фейковые** (детерминированный генератор). Требует подключения реального API бекенда через замену `generateChartData` в `chartStore.ts`
- Прогнозная линия — условная визуализация; реальная модель возвращает `prediction[]` из бекенда
- Блог — статические данные в коде; для production рекомендуется CMS или headless API
+2
View File
@@ -37,6 +37,8 @@
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gsap": "^3.15.0",
"lightweight-charts": "^5.2.0",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
} }
} }
+23
View File
@@ -11,6 +11,12 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
gsap:
specifier: ^3.15.0
version: 3.15.0
lightweight-charts:
specifier: ^5.2.0
version: 5.2.0
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@@ -820,6 +826,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
fancy-canvas@2.1.0:
resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -876,6 +885,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
hasown@2.0.3: hasown@2.0.3:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1013,6 +1025,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
lightweight-charts@5.2.0:
resolution: {integrity: sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==}
lilconfig@2.1.0: lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2048,6 +2063,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
fancy-canvas@2.1.0: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@@ -2089,6 +2106,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.15.0: {}
hasown@2.0.3: hasown@2.0.3:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -2191,6 +2210,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0
lightweight-charts@5.2.0:
dependencies:
fancy-canvas: 2.1.0
lilconfig@2.1.0: {} lilconfig@2.1.0: {}
locate-character@3.0.0: {} locate-character@3.0.0: {}
+66
View File
@@ -0,0 +1,66 @@
export type Post = {
id: number;
slug: string;
date: string;
title: string;
description: string;
tags: string[];
content: string;
};
export const posts: Post[] = [
{
id: 1,
slug: 'osnovy-upravleniya-riskami',
date: '2026-05-18',
title: 'Основы управления рисками',
description:
'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.',
tags: ['RISK', 'TRADING', 'EDUCATION'],
content: `
<p>Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.</p>
<h2>Stop Loss: где ваша позиция неправа</h2>
<p>Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy AI уровень SL рассчитывается моделью на основе исторической волатильности инструмента.</p>
<h2>Take Profit: когда забирать прибыль</h2>
<p>Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.</p>
<h2>Risk:Reward Ratio</h2>
<p>Оптимальное соотношение риска к доходности — не менее <strong>1:2</strong>. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.</p>
<ul>
<li>SL: не более 1% от капитала на сделку</li>
<li>TP: 2% и выше от точки входа</li>
<li>Одна сделка: не более 2% депозита</li>
</ul>
<h2>Почему это важно при работе с ML-прогнозами</h2>
<p>ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.</p>
`
},
{
id: 2,
slug: 'kak-chitat-grafiki-flamy-ai',
date: '2026-05-17',
title: 'Как читать графики Flamy AI',
description:
'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy AI.',
tags: ['ML', 'FORECAST', 'CHARTS'],
content: `
<p>Графики Flamy AI содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.</p>
<h2>Исторические свечи</h2>
<p>Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи (close &gt; open), красные — медвежьи. Это основа для анализа модели.</p>
<h2>Прогнозная зона</h2>
<p>Правая часть — прогнозные свечи на горизонт <strong>19 периодов</strong> вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.</p>
<h2>Уровни Entry, TP и SL</h2>
<p>Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии:</p>
<ul>
<li><strong>Entry</strong> — рекомендуемый уровень входа в позицию</li>
<li><strong>TP</strong> — цель Take Profit</li>
<li><strong>SL</strong> — Stop Loss для ограничения убытков</li>
</ul>
<h2>Таймфрейм и горизонт</h2>
<p>Все графики работают на таймфрейме <strong>5 минут</strong>. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.</p>
`
}
];
export function getPostBySlug(slug: string): Post | undefined {
return posts.find((p) => p.slug === slug);
}
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
createChart,
CandlestickSeries,
LineSeries,
CrosshairMode,
LineStyle,
type IChartApi,
type ISeriesApi,
type MouseEventParams,
type UTCTimestamp,
} from 'lightweight-charts';
import type { ChartData, CandleBar } from '$lib/stores/chartStore';
type Props = {
data: ChartData;
decimals: number;
height?: number;
};
let { data, decimals, height = 520 }: Props = $props();
let container = $state<HTMLDivElement>(null!);
let chart: IChartApi | undefined;
let candleSeries: ISeriesApi<'Candlestick'> | undefined;
let predSeries: ISeriesApi<'Line'> | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let priceLines: any[] = [];
type Tooltip = {
visible: boolean;
x: number;
y: number;
time: string;
open: number;
high: number;
low: number;
close: number;
isUp: boolean;
pred?: number;
};
let tip = $state<Tooltip>({
visible: false,
x: 0,
y: 0,
time: '',
open: 0,
high: 0,
low: 0,
close: 0,
isUp: true,
});
function fmt(n: number): string {
if (n >= 10000) return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
if (n >= 100) return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
if (n >= 1) return n.toLocaleString('en-US', { maximumFractionDigits: 4 });
return n.toLocaleString('en-US', { maximumFractionDigits: 6 });
}
function applyData(d: ChartData) {
if (!chart || !candleSeries || !predSeries) return;
candleSeries.setData(d.candles.map((c) => ({ ...c, time: c.time as UTCTimestamp })));
predSeries.setData(d.prediction.map((p) => ({ ...p, time: p.time as UTCTimestamp })));
// Remove old price lines then re-add
priceLines.forEach((pl) => candleSeries!.removePriceLine(pl));
priceLines = [
candleSeries.createPriceLine({
price: d.signal.entry,
color: '#fe4b07',
lineWidth: 1,
lineStyle: LineStyle.Solid,
axisLabelVisible: true,
title: 'Entry',
}),
candleSeries.createPriceLine({
price: d.signal.tp,
color: '#26a69a',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: true,
title: 'TP',
}),
candleSeries.createPriceLine({
price: d.signal.sl,
color: '#ef5350',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: true,
title: 'SL',
}),
];
chart.timeScale().fitContent();
}
onMount(() => {
chart = createChart(container, {
width: container.clientWidth,
height,
layout: {
background: { color: '#09080a' },
textColor: '#8a887f',
fontFamily: "'JetBrains Mono', monospace",
fontSize: 11,
attributionLogo: false,
},
grid: {
vertLines: { color: '#1a191e' },
horzLines: { color: '#1a191e' },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: '#3d3b42',
labelBackgroundColor: '#1a191e',
style: LineStyle.Dashed,
width: 1,
},
horzLine: {
color: '#3d3b42',
labelBackgroundColor: '#1a191e',
style: LineStyle.Dashed,
width: 1,
},
},
rightPriceScale: {
borderColor: '#1a191e',
scaleMargins: { top: 0.06, bottom: 0.04 },
},
timeScale: {
borderColor: '#1a191e',
timeVisible: true,
secondsVisible: false,
rightOffset: 24,
},
});
candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
});
predSeries = chart.addSeries(LineSeries, {
color: '#fe4b07',
lineWidth: 2,
lineStyle: LineStyle.Dashed,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
crosshairMarkerBorderColor: '#fe4b07',
crosshairMarkerBackgroundColor: '#09080a',
priceLineVisible: false,
lastValueVisible: true,
});
applyData(data);
chart.subscribeCrosshairMove((param: MouseEventParams) => {
if (!param.point || !param.time || !candleSeries || !param.seriesData.has(candleSeries)) {
tip = { ...tip, visible: false };
return;
}
const c = param.seriesData.get(candleSeries) as CandleBar;
if (!c) { tip = { ...tip, visible: false }; return; }
const pred = predSeries && param.seriesData.has(predSeries)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (param.seriesData.get(predSeries) as any)?.value
: undefined;
const ts = param.time as number;
const d = new Date(ts * 1000);
const timeStr = d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
+ ' ' + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
tip = {
visible: true,
x: param.point.x,
y: param.point.y,
time: timeStr,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
isUp: c.close >= c.open,
pred,
};
});
const ro = new ResizeObserver(() => {
chart?.applyOptions({
width: container.clientWidth,
height: container.clientHeight,
});
});
ro.observe(container);
return () => {
ro.disconnect();
chart?.remove();
};
});
// React to data prop changes after mount
$effect(() => {
const d = data;
void decimals; // track but unused — parent re-generates data on change
if (!chart || !candleSeries || !predSeries) return;
applyData(d);
});
</script>
<div class="relative w-full" bind:this={container} style="height: {height}px">
{#if tip.visible}
{@const maxX = container?.clientWidth ?? 500}
{@const left = tip.x + 160 > maxX ? tip.x - 168 : tip.x + 14}
{@const top = Math.max(tip.y - 90, 8)}
<div
class="pointer-events-none absolute z-20 min-w-36 rounded-xl border border-white/8
bg-bg-c/95 px-3 py-2.5 font-mono text-[0.6875rem] shadow-2xl backdrop-blur-sm"
style="left: {left}px; top: {top}px"
>
<p class="mb-2 text-[0.5625rem] tracking-widest text-desc uppercase">{tip.time}</p>
{#each [['O', tip.open], ['H', tip.high], ['L', tip.low], ['C', tip.close]] as [lbl, val]}
<div class="flex justify-between gap-5">
<span class="text-desc">{lbl}</span>
<span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span>
</div>
{/each}
{#if tip.pred !== undefined}
<div class="mt-1.5 flex justify-between gap-5 border-t border-white/8 pt-1.5">
<span class="text-primary text-[0.5625rem] tracking-widest uppercase">ML</span>
<span class="text-primary">{fmt(tip.pred)}</span>
</div>
{/if}
</div>
{/if}
</div>
@@ -62,7 +62,7 @@
} }
</script> </script>
<div class="w-full max-w-170 z-20 mx-auto lg:mx-0" style="perspective: 1000px;"> <div class="w-full max-w-170 z-20 mx-auto xl:mx-0" style="perspective: 1000px;">
<div <div
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform" class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform"
style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;" style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;"
+2 -2
View File
@@ -14,7 +14,7 @@
<div class="mx-auto max-w-400 px-5 py-20 sm:px-10"> <div class="mx-auto max-w-400 px-5 py-20 sm:px-10">
<div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between"> <div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between">
<div class="col-span-2 flex flex-col gap-4 sm:col-span-1"> <div class="col-span-2 flex flex-col gap-4 lg:col-span-1">
<Logo /> <Logo />
<p class="max-w-55 text-base leading-[1.2] text-zinc-600"> <p class="max-w-55 text-base leading-[1.2] text-zinc-600">
@@ -34,7 +34,7 @@
{/each} {/each}
</div> </div>
<div class="col-span-2 flex flex-col gap-1.5 sm:col-span-2 ml-auto"> <div class="col-span-2 flex flex-col gap-1.5 sm:col-span-2 lg:ml-auto">
<span class="font-mono text-base tracking-widest text-zinc-600 uppercase">Дисклеймер</span> <span class="font-mono text-base tracking-widest text-zinc-600 uppercase">Дисклеймер</span>
<p class="max-w-85 text-sm leading-relaxed text-zinc-600"> <p class="max-w-85 text-sm leading-relaxed text-zinc-600">
Материалы являются результатом работы исследовательской ML-модели и используются в Материалы являются результатом работы исследовательской ML-модели и используются в
+20 -8
View File
@@ -1,22 +1,35 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { gsap } from 'gsap';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import Logo from '$lib/components/ui/Logo.svelte'; import Logo from '$lib/components/ui/Logo.svelte';
const menuLinks = [ const menuLinks = [
{ id: 0, href: 'reg', text: 'Главная' }, { id: 0, href: '/', text: 'Главная' },
{ id: 1, href: 'reg', text: 'О проекте' }, { id: 1, href: '/about', text: 'О проекте' },
{ id: 2, href: 'reg', text: 'Блог' }, { id: 2, href: '/blog', text: 'Блог' },
{ id: 3, href: 'reg', text: 'Открыть дашборд', isButton: true } { id: 3, href: '/dashboard', text: 'Открыть дашборд', isButton: true }
]; ];
let mobileOpen = $state(false); let mobileOpen = $state(false);
let header = $state<HTMLElement>(null!);
onMount(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
gsap.from(header, {
y: -10,
opacity: 0,
duration: 0.5,
ease: 'power2.out',
clearProps: 'transform,opacity',
});
});
</script> </script>
<header class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999"> <header bind:this={header} class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999">
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-0"> <div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5">
<Logo /> <Logo />
<!-- Десктопная навигация -->
<nav class="hidden lg:block"> <nav class="hidden lg:block">
<ul class="flex items-center justify-center gap-5.5 transition-colors duration-300"> <ul class="flex items-center justify-center gap-5.5 transition-colors duration-300">
{#each menuLinks as link (link.id)} {#each menuLinks as link (link.id)}
@@ -32,7 +45,6 @@
</ul> </ul>
</nav> </nav>
<!-- Мобильная кнопка гамбургера -->
<button <button
class="flex flex-col justify-center gap-1.5 p-1 lg:hidden" class="flex flex-col justify-center gap-1.5 p-1 lg:hidden"
onclick={() => (mobileOpen = !mobileOpen)} onclick={() => (mobileOpen = !mobileOpen)}
+4 -2
View File
@@ -6,10 +6,11 @@
href?: string; href?: string;
target?: string; target?: string;
variant?: 'primary' | 'secondary'; variant?: 'primary' | 'secondary';
class?: string;
children: Snippet; children: Snippet;
}; };
let { href, target = '_self', variant = 'primary', children }: Props = $props(); let { href, target = '_self', variant = 'primary', class: className, children }: Props = $props();
</script> </script>
<svelte:element <svelte:element
@@ -20,7 +21,8 @@
class={cn( class={cn(
'flex items-center justify-center gap-2 w-fit rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300', 'flex items-center justify-center gap-2 w-fit rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300',
variant === 'primary' && 'bg-primary hover:bg-primary-h', variant === 'primary' && 'bg-primary hover:bg-primary-h',
variant === 'secondary' && 'bg-bg border hover:text-desc' variant === 'secondary' && 'bg-bg border hover:text-desc',
className
)} )}
> >
{@render children()} {@render children()}
+168
View File
@@ -0,0 +1,168 @@
/**
* Svelte actions wrapping GSAP + ScrollTrigger.
*
* Each action returns { destroy } for automatic cleanup when the element
* leaves the DOM. gsap.context() scopes all tweens so they're properly
* killed in destroy, preventing memory leaks on navigation.
*
* ScrollTrigger is registered once on first import (browser-only).
*/
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
}
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
}
// ─── Types ──────────────────────────────────────────────────────────────────
export type FadeUpParams = {
y?: number;
delay?: number;
duration?: number;
start?: string;
};
export type RevealLeftParams = {
x?: number;
delay?: number;
duration?: number;
start?: string;
};
export type StaggerParams = {
y?: number;
x?: number;
delay?: number;
duration?: number;
stagger?: number;
start?: string;
selector?: string;
};
// ─── Actions ────────────────────────────────────────────────────────────────
/**
* Fade up a single element when it scrolls into view.
* use:scrollFadeUp or use:scrollFadeUp={{ y: 24, delay: 0.15 }}
*/
export function scrollFadeUp(el: HTMLElement, params: FadeUpParams = {}) {
if (prefersReducedMotion()) return;
const { y = 36, delay = 0, duration = 0.65, start = 'top 84%' } = params;
gsap.set(el, { opacity: 0, y });
const ctx = gsap.context(() => {
gsap.to(el, {
opacity: 1,
y: 0,
duration,
delay,
ease: 'power3.out',
clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true },
});
});
return { destroy: () => ctx.revert() };
}
/**
* Slide in from the left when element scrolls into view — good for headings.
* use:scrollRevealLeft
*/
export function scrollRevealLeft(el: HTMLElement, params: RevealLeftParams = {}) {
if (prefersReducedMotion()) return;
const { x = -48, delay = 0, duration = 0.7, start = 'top 82%' } = params;
gsap.set(el, { opacity: 0, x });
const ctx = gsap.context(() => {
gsap.to(el, {
opacity: 1,
x: 0,
duration,
delay,
ease: 'power3.out',
clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true },
});
});
return { destroy: () => ctx.revert() };
}
/**
* Stagger-animate direct children (or custom selector) of a container
* when it scrolls into view.
* use:scrollStagger or use:scrollStagger={{ stagger: 0.08, selector: 'li' }}
*/
export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
if (prefersReducedMotion()) return;
const {
y = 32,
x = 0,
delay = 0,
duration = 0.65,
stagger = 0.1,
start = 'top 80%',
selector = ':scope > *',
} = params;
const targets = el.querySelectorAll<HTMLElement>(selector);
if (!targets.length) return;
gsap.set(targets, { opacity: 0, y, x });
const ctx = gsap.context(() => {
gsap.to(targets, {
opacity: 1,
y: 0,
x: 0,
duration,
delay,
ease: 'power3.out',
stagger: { each: stagger, ease: 'power1.inOut' },
clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true },
});
});
return { destroy: () => ctx.revert() };
}
/**
* Scale + fade in a single element — good for cards and panels.
* use:scrollScaleIn
*/
export function scrollScaleIn(el: HTMLElement, params: { delay?: number; start?: string } = {}) {
if (prefersReducedMotion()) return;
const { delay = 0, start = 'top 82%' } = params;
gsap.set(el, { opacity: 0, scale: 0.94, y: 20 });
const ctx = gsap.context(() => {
gsap.to(el, {
opacity: 1,
scale: 1,
y: 0,
duration: 0.7,
delay,
ease: 'power3.out',
clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true },
});
});
return { destroy: () => ctx.revert() };
}
+141
View File
@@ -0,0 +1,141 @@
export type TimeframeId = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
export type Direction = 'long' | 'short';
export type CandleBar = {
time: number;
open: number;
high: number;
low: number;
close: number;
};
export type PredPoint = {
time: number;
value: number;
};
export type Signal = {
direction: Direction;
confidence: number;
entry: number;
tp: number;
sl: number;
};
export type ChartData = {
candles: CandleBar[];
prediction: PredPoint[];
signal: Signal;
currentPrice: number;
change24h: number;
change24hPct: number;
};
export const SYMBOLS = [
{ id: 'BTCUSDT', label: 'BTC/USDT', short: 'BTC', base: 103500, dec: 1 },
{ id: 'ETHUSDT', label: 'ETH/USDT', short: 'ETH', base: 3800, dec: 2 },
{ id: 'SOLUSDT', label: 'SOL/USDT', short: 'SOL', base: 176, dec: 3 },
{ id: 'BNBUSDT', label: 'BNB/USDT', short: 'BNB', base: 585, dec: 2 },
{ id: 'XRPUSDT', label: 'XRP/USDT', short: 'XRP', base: 0.55, dec: 5 },
{ id: 'ADAUSDT', label: 'ADA/USDT', short: 'ADA', base: 0.43, dec: 5 },
{ id: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 },
{ id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 },
] as const;
export const TIMEFRAMES = [
{ id: '1m' as TimeframeId, label: '1м', sec: 60 },
{ id: '5m' as TimeframeId, label: '5м', sec: 300 },
{ id: '15m' as TimeframeId, label: '15м', sec: 900 },
{ id: '1h' as TimeframeId, label: '1ч', sec: 3600 },
{ id: '4h' as TimeframeId, label: '4ч', sec: 14400 },
{ id: '1d' as TimeframeId, label: '1д', sec: 86400 },
];
// Simple LCG PRNG — deterministic per seed so switching back gives the same chart
function rng(seed: number) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 0x100000000;
};
}
const HISTORY = 160;
const HORIZON = 19;
export function generateChartData(symbolId: string, tfId: TimeframeId): ChartData {
const sym = SYMBOLS.find((s) => s.id === symbolId) ?? SYMBOLS[0];
const tf = TIMEFRAMES.find((t) => t.id === tfId) ?? TIMEFRAMES[1];
const seed =
[...symbolId].reduce((a, c, i) => a + c.charCodeAt(0) * (i + 1), 0) +
[...tfId].reduce((a, c, i) => a + c.charCodeAt(0) * (i + 1), 0);
const rand = rng(seed);
const now = Math.floor(Date.now() / tf.sec) * tf.sec;
const startTime = now - HISTORY * tf.sec;
const vol = sym.base * 0.004;
let price = sym.base * (0.88 + rand() * 0.24);
const trendBias = (rand() - 0.5) * 0.001;
const candles: CandleBar[] = [];
for (let i = 0; i < HISTORY; i++) {
const open = price;
const move = (rand() - 0.5 + trendBias) * vol * 2;
const close = Math.max(open + move, open * 0.001);
const bodyH = Math.abs(close - open);
const wk = 0.3 + rand() * 0.8;
const high = Math.max(open, close) + bodyH * wk + rand() * vol * 0.2;
const low = Math.min(open, close) - bodyH * wk * 0.7 - rand() * vol * 0.2;
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
candles.push({ time: startTime + i * tf.sec, open: round(open), high: round(high), low: round(low), close: round(close) });
price = close;
}
const currentPrice = candles[candles.length - 1].close;
// 24h change — look back ~288 candles for 5m, scaled to timeframe
const lookback = Math.min(Math.round(86400 / tf.sec), HISTORY - 1);
const oldPrice = candles[HISTORY - 1 - lookback].close;
const change24h = currentPrice - oldPrice;
const change24hPct = (change24h / oldPrice) * 100;
// ML prediction — starts at last candle and extends HORIZON candles forward
const predDir = rand() > 0.42 ? 1 : -1;
const predStrength = (0.006 + rand() * 0.018) * sym.base;
const prediction: PredPoint[] = [
{ time: candles[HISTORY - 1].time, value: currentPrice },
];
let pPrice = currentPrice;
for (let i = 1; i <= HORIZON; i++) {
const t = i / HORIZON;
const noise = (rand() - 0.5) * vol * 0.6;
pPrice = currentPrice + predDir * predStrength * t + noise;
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
prediction.push({ time: now + i * tf.sec, value: round(Math.max(pPrice, currentPrice * 0.1)) });
}
const finalPred = prediction[prediction.length - 1].value;
const predRange = Math.abs(finalPred - currentPrice);
const direction: Direction = predDir > 0 ? 'long' : 'short';
const confidence = Math.round(52 + rand() * 33);
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
const entry = round(currentPrice);
const tp = round(direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3);
const sl = round(direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65);
return {
candles,
prediction,
signal: { direction, confidence, entry, tp, sl },
currentPrice,
change24h,
change24hPct,
};
}
@@ -1,21 +1,33 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import DashboardStatus from '$lib/components/DashboardStatus.svelte'; import DashboardStatus from '$lib/components/home/DashboardStatus.svelte';
import { scrollRevealLeft, scrollFadeUp, scrollStagger } from '$lib/gsap/actions';
</script> </script>
<div class="relative bg-bg-e"> <div class="relative bg-bg-e">
<section <section
class="flex flex-col items-start gap-10 px-5 py-16 class="flex flex-col items-start gap-10 px-5 py-16
sm:px-10 sm:py-20 sm:px-10 sm:py-20
lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:h-[70vh] lg:py-0" lg:h-[70vh] lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:py-0"
> >
<div> <div>
<h2 class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl">Дашборд</h2> <h2
<p class="mt-6 max-w-full text-base text-desc sm:text-lg sm:mt-8 lg:mt-8 lg:max-w-140 lg:text-xl"> use:scrollRevealLeft
class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl"
>Дашборд</h2>
<p
use:scrollFadeUp={{ delay: 0.1 }}
class="mt-6 max-w-full text-base text-desc sm:mt-8 sm:text-lg lg:mt-8 lg:max-w-140 lg:text-xl"
>
Все инструменты одним взглядом. Интерактивные графики, уровни входа и выхода, статистика Все инструменты одним взглядом. Интерактивные графики, уровни входа и выхода, статистика
winrate — доступно без регистрации. winrate — доступно без регистрации.
</p> </p>
<ul class="mt-5 mb-8 flex flex-col gap-2 text-base text-desc sm:text-lg lg:text-xl lg:gap-1 lg:mt-6 lg:mb-10">
<ul
use:scrollStagger={{ selector: 'li', stagger: 0.07, y: 16, delay: 0.05 }}
class="mt-5 mb-8 flex flex-col gap-2 text-base text-desc sm:text-lg lg:mt-6 lg:mb-10 lg:gap-1 lg:text-xl"
>
<li class="flex items-center gap-2"> <li class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full bg-primary"></span> <span class="h-2 w-2 shrink-0 rounded-full bg-primary"></span>
Интерактивные свечные графики с прогнозом Интерактивные свечные графики с прогнозом
@@ -29,11 +41,14 @@
Поиск и фильтрация по направлению Поиск и фильтрация по направлению
</li> </li>
</ul> </ul>
<Button href="/dashboard">Перейти к дашборду</Button>
<div use:scrollFadeUp={{ delay: 0.2 }}>
<Button href="/dashboard">Перейти к дашборду</Button>
</div>
</div> </div>
<DashboardStatus /> <div use:scrollFadeUp={{ y: 24, delay: 0.15 }} class="w-full max-w-140 shrink-0">
<DashboardStatus />
</div>
</section> </section>
<div class="noise"></div>
</div> </div>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { scrollFadeUp, scrollStagger } from '$lib/gsap/actions';
type Post = { type Post = {
slug: string; slug: string;
date: string; date: string;
@@ -25,8 +27,11 @@
{#if posts.length > 0} {#if posts.length > 0}
<div class="border-b border-white/6"> <div class="border-b border-white/6">
<section class="px-5 py-24 sm:px-10 sm:py-32"> <section class="px-5 py-24 sm:px-10 sm:py-32">
<div class="mb-12 flex items-end justify-between"> <div
<h2 class="font-display text-4xl font-black uppercase tracking-tighter">БЛОГ</h2> use:scrollFadeUp={{ y: 20 }}
class="mb-12 flex items-end justify-between"
>
<h2 class="font-display text-4xl font-black tracking-tighter uppercase">БЛОГ</h2>
<a <a
href="/blog" href="/blog"
class="font-display text-[0.75rem] font-bold tracking-wider text-desc transition-colors duration-150 hover:text-white" class="font-display text-[0.75rem] font-bold tracking-wider text-desc transition-colors duration-150 hover:text-white"
@@ -36,6 +41,7 @@
</div> </div>
<div <div
use:scrollStagger={{ selector: 'a', stagger: 0.1, y: 32 }}
class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]" class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]"
> >
{#each posts as post, i} {#each posts as post, i}
@@ -45,17 +51,20 @@
{i > 0 ? 'border-t border-white/6 sm:border-t-0 sm:border-l' : ''}" {i > 0 ? 'border-t border-white/6 sm:border-t-0 sm:border-l' : ''}"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<time datetime={post.date} class="font-mono text-[0.6875rem] tracking-widest text-zinc-600"> <time
datetime={post.date}
class="font-mono text-[0.6875rem] tracking-widest text-zinc-600"
>
{fmtDate(post.date)} {fmtDate(post.date)}
</time> </time>
{#if post.tags?.length} {#if post.tags?.length}
<span class="font-mono text-[0.625rem] uppercase tracking-widest text-zinc-600"> <span class="font-mono text-[0.625rem] tracking-widest text-zinc-600 uppercase">
{post.tags[0]} {post.tags[0]}
</span> </span>
{/if} {/if}
</div> </div>
<h3 <h3
class="font-display font-black leading-tight tracking-tight text-[clamp(1.25rem,2.5vw,1.75rem)]" class="font-display text-[clamp(1.25rem,2.5vw,1.75rem)] leading-tight font-black tracking-tight"
> >
{post.title} {post.title}
</h3> </h3>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from 'svelte';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Button from '$lib/components/ui/Button.svelte';
let section: HTMLElement;
onMount(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const heading = section.querySelector<HTMLElement>('[data-cta-heading]');
const btnWrap = section.querySelector<HTMLElement>('[data-cta-btn]');
const disclaimer = section.querySelector<HTMLElement>('[data-cta-note]');
if (heading) {
gsap.set(heading, { opacity: 0, scale: 0.88, y: 32 });
gsap.to(heading, {
opacity: 1, scale: 1, y: 0,
duration: 0.8, ease: 'power3.out',
clearProps: 'transform,opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
});
}
if (btnWrap) {
gsap.set(btnWrap, { opacity: 0, y: 20 });
gsap.to(btnWrap, {
opacity: 1, y: 0,
duration: 0.6, delay: 0.2, ease: 'power3.out',
clearProps: 'transform,opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
});
}
if (disclaimer) {
gsap.set(disclaimer, { opacity: 0 });
gsap.to(disclaimer, {
opacity: 1,
duration: 0.6, delay: 0.4,
clearProps: 'opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true },
});
}
}, section);
return () => ctx.revert();
});
</script>
<div class="relative overflow-hidden">
<section
bind:this={section}
class="z-20 flex min-h-[70vh] flex-col items-center justify-center overflow-hidden px-5 text-center sm:px-10 lg:h-[80vh] lg:min-h-0"
>
<div class="relative flex flex-col items-center justify-center gap-6 lg:gap-8">
<h2
data-cta-heading
class="font-display text-[clamp(2.5rem,10vw,9rem)] leading-[0.9] font-black tracking-tighter uppercase"
>
ОТКРОЙТЕ<br /><span class="text-primary">ДАШБОРД</span>
</h2>
<div data-cta-btn>
<Button href="/dashboard">Открыть дашборд</Button>
</div>
<p data-cta-note class="font-mono text-sm leading-[1.2] text-zinc-600 sm:text-lg">
Материалы носят исследовательский характер.<br />Не являются инвестиционными рекомендациями.
</p>
</div>
</section>
<div
class="pointer-events-none absolute -top-210 left-0 z-10 h-320 w-140 rotate-80 rounded-full bg-primary/20 blur-3xl md:-top-180"
></div>
<div
class="pointer-events-none absolute right-0 -bottom-210 z-10 h-320 w-140 rotate-80 rounded-full bg-primary/20 blur-3xl md:-bottom-180"
></div>
</div>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { scrollRevealLeft, scrollStagger } from '$lib/gsap/actions';
type Feature = { num: string; title: string; desc: string }; type Feature = { num: string; title: string; desc: string };
const features: Feature[] = [ const features: Feature[] = [
@@ -37,16 +39,23 @@
<div class="border-b border-white/6"> <div class="border-b border-white/6">
<section class="px-5 py-24 sm:px-10 sm:py-36"> <section class="px-5 py-24 sm:px-10 sm:py-36">
<h2 class="mb-16 font-display text-4xl font-black uppercase tracking-tighter">ВОЗМОЖНОСТИ</h2> <h2
use:scrollRevealLeft
class="mb-16 font-display text-4xl font-black tracking-tighter uppercase"
>ВОЗМОЖНОСТИ</h2>
<div class="border-t border-white/6"> <div
use:scrollStagger={{ selector: '[data-feature-row]', stagger: 0.07, y: 28 }}
class="border-t border-white/6"
>
{#each features as f (f.num)} {#each features as f (f.num)}
<div <div
data-feature-row
class="group cursor-default border-b border-white/6 py-6 transition-colors duration-150 hover:bg-primary/2.5 class="group cursor-default border-b border-white/6 py-6 transition-colors duration-150 hover:bg-primary/2.5
md:grid md:grid-cols-[80px_1fr_1fr] md:items-center md:gap-8 md:py-8" md:grid md:grid-cols-[80px_1fr_1fr] md:items-center md:gap-8 md:py-8"
> >
<span <span
class="block font-display text-3xl font-black leading-none tracking-tighter text-primary opacity-20 transition-opacity duration-150 group-hover:opacity-100 class="block font-display text-3xl leading-none font-black tracking-tighter text-primary opacity-20 transition-opacity duration-150 group-hover:opacity-100
md:text-[clamp(2rem,4vw,3.5rem)]" md:text-[clamp(2rem,4vw,3.5rem)]"
> >
{f.num} {f.num}
@@ -63,7 +72,5 @@
</div> </div>
{/each} {/each}
</div> </div>
<div class="noise"></div>
</section> </section>
</div> </div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from 'svelte';
import { gsap } from 'gsap';
import Button from '$lib/components/ui/Button.svelte';
import DashboardMockup from '$lib/components/home/DashboardMockup.svelte';
let section: HTMLElement;
onMount(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const ctx = gsap.context(() => {
const words = section.querySelectorAll<HTMLElement>('[data-hero-word]');
const desc = section.querySelector<HTMLElement>('[data-hero-desc]');
const cta = section.querySelector<HTMLElement>('[data-hero-cta]');
const mock = section.querySelector<HTMLElement>('[data-hero-mockup]');
gsap.set(words, { opacity: 0, y: 44, skewX: -6 });
gsap.set(desc, { opacity: 0, y: 24 });
gsap.set(cta, { opacity: 0, y: 16 });
gsap.set(mock, { opacity: 0, x: 48 });
const tl = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => gsap.set([words, desc, cta, mock], { clearProps: 'all' }),
});
words.forEach((w, i) =>
tl.to(w, { opacity: 1, y: 0, skewX: 0, duration: 0.75 }, 0.05 + i * 0.1)
);
tl.to(desc, { opacity: 1, y: 0, duration: 0.65, ease: 'power2.out' }, 0.42);
tl.to(cta, { opacity: 1, y: 0, duration: 0.55, ease: 'expo.out' }, 0.58);
tl.to(mock, { opacity: 1, x: 0, duration: 0.9, ease: 'power2.out' }, 0.18);
}, section);
return () => ctx.revert();
});
</script>
<section
bind:this={section}
class="flex min-h-screen flex-col gap-10 px-5 pt-24 pb-14
sm:px-10 sm:pt-28
xl:h-screen xl:min-h-0 xl:flex-row xl:items-center xl:justify-between xl:gap-0 xl:px-5 xl:pt-16 xl:pb-0"
>
<div class="z-20 xl:transform-[perspective(1000px)_rotateY(6deg)_rotateX(3deg)]">
<h1 class="flex flex-col font-display leading-[0.95] font-black uppercase">
<span data-hero-word class="text-[clamp(2.5rem,10vw,6.25rem)]">Рыночный</span>
<span data-hero-word class="text-[clamp(3rem,13vw,8rem)]">прогноз</span>
<span data-hero-word class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary">от ML-модели</span>
</h1>
<p
data-hero-desc
class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:mt-8 sm:text-xl lg:mt-10 xl:max-w-180 lg:text-2xl lg:leading-[1.1]"
>
Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном
графике. Горизонт 19 свечей, таймфрейм 5M.
</p>
<div data-hero-cta class="mt-8 flex items-center justify-start gap-4 lg:mt-10">
<Button href="/dashboard">Открыть дашборд</Button>
<Button href="/about" variant="secondary">Как работает</Button>
</div>
</div>
<div data-hero-mockup class="w-full mx-auto xl:mx-0 z-20">
<DashboardMockup />
</div>
<!-- Визуальные элементы -->
<span
class="absolute -top-60 left-0 z-0 h-80 w-full rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
></span>
<span class="absolute bottom-35 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
<span class="absolute top-55 left-0 z-0 hidden h-px w-full bg-bg-h xl:block"></span>
</section>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { scrollStagger } from '$lib/gsap/actions';
type Stat = { num: string; suffix: string; label: string; sub: string }; type Stat = { num: string; suffix: string; label: string; sub: string };
const stats: Stat[] = [ const stats: Stat[] = [
@@ -8,40 +10,41 @@
]; ];
</script> </script>
<div class="relative border-b border-white/6 bg-bg-e"> <div class="border-b border-white/6 bg-bg-e">
<div class="noise"></div> <section class="flex items-center justify-center px-5 py-14 sm:px-10 sm:py-20 lg:py-0">
<section class="flex justify-center items-center px-5 py-14 sm:px-10 sm:py-20 lg:py-0">
<div <div
class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 w-full sm:w-max sm:grid-cols-3" use:scrollStagger={{ selector: '[data-stat]', stagger: 0.12, y: 28 }}
class="grid w-full grid-cols-1 overflow-hidden rounded-xl border border-white/6 md:w-max md:grid-cols-3"
> >
{#each stats as stat, i (stat.num)} {#each stats as stat, i (stat.num)}
<div <div
class="relative overflow-hidden px-8 py-10 border-white/6 sm:px-14 sm:py-16 data-stat
class="relative overflow-hidden border-white/6 px-8 py-10 sm:px-14 sm:py-16
{i > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''}" {i > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''}"
> >
<div <div
class="pointer-events-none absolute w-full h-full left-0 -bottom-80 bg-primary/40 rounded-3xl blur-3xl" class="pointer-events-none absolute -bottom-80 left-0 h-full w-full rounded-3xl bg-primary/40 blur-3xl"
></div> ></div>
<div class="relative flex flex-col gap-4 text-center"> <div class="relative flex flex-col gap-4 text-center">
<div class="flex items-baseline justify-center gap-1"> <div class="flex items-baseline justify-center gap-1">
<span <span
class="font-display font-black leading-none tracking-tighter text-[clamp(3.5rem,9vw,8rem)]" class="font-display text-[clamp(3.5rem,9vw,8rem)] leading-none font-black tracking-tighter"
> >
{stat.num} {stat.num}
</span> </span>
{#if stat.suffix} {#if stat.suffix}
<span <span
class="font-display font-black leading-none tracking-tighter text-primary text-[clamp(2rem,5vw,4rem)]" class="font-display text-[clamp(2rem,5vw,4rem)] leading-none font-black tracking-tighter text-primary"
> >
{stat.suffix} {stat.suffix}
</span> </span>
{/if} {/if}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-mono text-lg tracking-widest leading-none text-primary"> <span class="font-mono text-lg leading-none tracking-widest text-primary">
{stat.label} {stat.label}
</span> </span>
<span class="text-xl text-desc leading-[1.2]">{stat.sub}</span> <span class="text-xl leading-[1.2] text-desc">{stat.sub}</span>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -17,6 +17,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head> </svelte:head>
<div class="noise"></div>
<Header /> <Header />
{@render children()} {@render children()}
<Footer /> <Footer />
+8 -7
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import Hero from './_sections/Hero.svelte';
import Ticker from './_sections/Ticker.svelte'; import Hero from './(sections)/Hero.svelte';
import About from './_sections/About.svelte'; import Ticker from './(sections)/Ticker.svelte';
import Features from './_sections/Features.svelte'; import About from './(sections)/About.svelte';
import Stats from './_sections/Stats.svelte'; import Features from './(sections)/Features.svelte';
import BlogPreview from './_sections/BlogPreview.svelte'; import Stats from './(sections)/Stats.svelte';
import Cta from './_sections/Cta.svelte'; import BlogPreview from './(sections)/BlogPreview.svelte';
import Cta from './(sections)/Cta.svelte';
</script> </script>
<main> <main>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
</script>
<div class="relative overflow-hidden">
<section
class="flex flex-col items-center justify-center overflow-hidden text-center z-20 px-5 min-h-[70vh] sm:px-10 lg:h-[80vh] lg:min-h-0"
>
<div class="relative flex flex-col items-center justify-center gap-6 lg:gap-8">
<h2
class="font-display font-black uppercase leading-[0.9] tracking-tighter text-[clamp(2.5rem,10vw,9rem)]"
>
ОТКРОЙТЕ<br /><span class="text-primary">ДАШБОРД</span>
</h2>
<Button href="/dashboard">Открыть дашборд</Button>
<p class="font-mono text-sm leading-[1.2] text-zinc-600 sm:text-lg">
Материалы носят исследовательский характер.<br />Не являются инвестиционными рекомендациями.
</p>
</div>
</section>
<div
class="pointer-events-none absolute -top-180 left-0 w-140 h-320 rotate-80 bg-primary/20 rounded-full blur-3xl z-10"
></div>
<div
class="pointer-events-none absolute -bottom-180 right-0 w-140 h-320 rotate-80 bg-primary/20 rounded-full blur-3xl z-10"
></div>
<div class="noise"></div>
</div>
-38
View File
@@ -1,38 +0,0 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import DashboardMockup from '$lib/components/DashboardMockup.svelte';
</script>
<section
class="flex flex-col gap-10 min-h-screen px-5 pt-24 pb-14
sm:px-10 sm:pt-28
lg:flex-row lg:items-center lg:justify-between lg:h-screen lg:min-h-0 lg:px-0 lg:pt-16 lg:pb-0 lg:gap-0"
>
<div class="z-20 lg:[transform:perspective(1000px)_rotateY(6deg)_rotateX(3deg)]">
<h1 class="flex flex-col font-display leading-[0.95] font-black uppercase">
<span class="text-[clamp(2.5rem,10vw,6.25rem)]"> Рыночный </span>
<span class="text-[clamp(3rem,13vw,8rem)]"> прогноз </span>
<span class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary"> от ML-модели </span>
</h1>
<p class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:text-xl sm:mt-8 lg:mt-10 lg:max-w-180 lg:text-2xl lg:leading-[1.1]">
Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном
графике. Горизонт 19 свечей, таймфрейм 5M.
</p>
<div class="mt-8 flex items-center justify-start gap-4 lg:mt-10">
<Button href="/dashboard">Открыть дашборд</Button>
<Button href="/about" variant="secondary">Как работает</Button>
</div>
</div>
<DashboardMockup />
<!-- Визуальные элементы -->
<span
class="absolute -top-60 left-0 z-0 h-80 w-full rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
></span>
<span class="absolute bottom-35 left-0 z-0 h-px w-full bg-bg-h hidden lg:block"></span>
<span class="absolute top-55 left-0 z-0 h-px w-full bg-bg-h hidden lg:block"></span>
<div class="noise"></div>
</section>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import { about, params} from './_data';
import { scrollFadeUp, scrollRevealLeft, scrollStagger } from '$lib/gsap/actions';
</script>
<main>
<div class="relative border-b border-b-bg-h bg-bg-e">
<section
class="z-10 flex h-screen flex-col items-start justify-end py-16
sm:py-20"
>
<h1
use:scrollRevealLeft={{ start: 'top 95%' }}
class="font-display text-[clamp(2.5rem,10vw,8rem)] leading-none font-black uppercase"
>
О проекте
</h1>
<p
use:scrollFadeUp={{ y: 20, delay: 0.1, start: 'top 95%' }}
class="mt-4 max-w-xs text-xl leading-[1.2] text-desc sm:max-w-140 sm:text-2xl"
>
Публичная витрина результатов ML-модели, обученной на исторических рыночных данных.
</p>
</section>
<span
class="absolute -top-20 -right-20 z-0 h-80 w-full rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
></span>
</div>
<section class="grid max-h-full! grid-cols-1 lg:grid-cols-[1fr_420px]">
<div class="border-b border-b-bg-h lg:border-r lg:border-b-0 lg:border-r-bg-h">
{#each about as item (item.id)}
{#if item.type === 'text'}
<div
use:scrollFadeUp={{ y: 28 }}
class="flex items-start gap-5 border-b border-b-bg-h px-5 py-12
sm:gap-10 sm:px-10 sm:py-24"
>
<span
class="shrink-0 font-display text-[clamp(3rem,8vw,6rem)] leading-[0.8] font-black text-primary
opacity-20"
>
0{item.id}
</span>
<div class="flex flex-col gap-4 sm:gap-6">
<p class="font-mono text-xs tracking-widest text-desc uppercase">{item.desc}</p>
<h2 class="font-display text-xl leading-none font-black sm:text-[2rem]">
{item.title}
</h2>
{#each item.text as paragraph, i (i)}
<p class="text-base leading-[1.4] text-desc sm:text-lg sm:leading-[1.3]">
{paragraph}
</p>
{/each}
</div>
</div>
{:else}
<!-- padding на родителе — не margin/w-full на самом блоке -->
<div class="border-b border-b-bg-h px-5 py-12 sm:px-10 sm:py-16">
<div
class="flex h-[50vh] w-full items-center justify-center rounded-2xl
border border-white/6 bg-bg-e"
>
<p class="font-mono text-xs tracking-widest text-desc/40 uppercase">
Дашборд — скриншот
</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="px-5 py-12 sm:px-8 sm:py-24">
<div class="overflow-hidden rounded-xl border border-white/6 bg-bg-e">
<p class="px-5 py-3 pt-5 text-center font-mono text-sm tracking-widest text-desc uppercase">
Параметры модели
</p>
<div class="divide-y divide-white/6">
{#each params as p (p.label)}
<div class="flex items-center justify-between px-5 py-3">
<span class="text-sm text-desc">{p.label}</span>
<span class="font-mono text-sm text-primary">{p.value}</span>
</div>
{/each}
</div>
</div>
<div class="my-4 rounded-xl border border-primary/20 bg-primary/5 px-5 py-6">
<p
class="mb-2 flex items-center gap-2 font-display text-xs font-bold tracking-widest text-primary uppercase"
>
⚠ Дисклеймер
</p>
<p class="text-sm leading-normal text-desc">
Материалы являются результатом работы исследовательской ML-модели и используются в
демонстрационных целях. Не являются индивидуальной инвестиционной рекомендацией.
</p>
</div>
<Button href="/dashboard" class="w-full">Открыть дашборд</Button>
</div>
</section>
</main>
+55
View File
@@ -0,0 +1,55 @@
type AboutText = {
id: number;
type: 'text';
desc: string;
title: string;
text: string[];
};
type AboutImage = {
id: number;
type: 'image';
src: string;
};
type About = AboutText | AboutImage;
export const about: About[] = [
{
id: 1,
type: 'text',
desc: 'Назначение',
title: 'Исследовательская платформа',
text: [
'Flamy AI — публичная витрина результатов ML-модели, обученной на исторических рыночных данных. Показывает прогнозы по крипто-инструментам: направление, уровни входа, Take Profit и Stop Loss.',
'Платформа создана для изучения поведения ML-модели в рыночных условиях — не для торговли реальными средствами.'
]
},
{ id: 0, type: 'image', src: '' },
{
id: 2,
type: 'text',
desc: 'Как работает',
title: 'Модель → Публикация → График',
text: [
'Каждый час модель обрабатывает последние свечи по каждому инструменту и публикует прогноз: направление (bullish / bearish / flat), уверенность в процентах, прогнозные OHLC-свечи на горизонт 19 периодов.',
'Если режим публикации TRADE_PLAN, модель также рассчитывает уровни Entry, TP и SL.'
]
},
{
id: 3,
type: 'text',
desc: 'Разработка',
title: 'Сделано в Flamy Studio',
text: [
'Платформа разработана командой Flamy Studio — студии веб-разработки, специализирующейся на современных продуктах.'
]
}
];
export const params = [
{ label: 'Горизонт', value: '19 свечей' },
{ label: 'Таймфрейм', value: '5 минут' },
{ label: 'История', value: '20 дней' },
{ label: 'Обновление', value: 'Каждый час' },
{ label: 'Стек', value: 'SvelteKit' },
{ label: 'Графики', value: 'lwc' }
];
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import { posts } from '$lib/blog/posts';
import { scrollRevealLeft, scrollFadeUp, scrollStagger } from '$lib/gsap/actions';
function fmtDate(d: string) {
try {
return new Intl.DateTimeFormat('ru', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(d));
} catch {
return d;
}
}
</script>
<main>
<div class="relative overflow-hidden border-b border-bg-h bg-bg-e">
<section class="z-10 flex flex-col items-start justify-end px-5 py-16 sm:px-10 sm:py-20">
<h1
use:scrollRevealLeft={{ start: 'top 95%' }}
class="font-display text-[clamp(2.5rem,10vw,8rem)] font-black uppercase leading-none"
>
Блог
</h1>
<p
use:scrollFadeUp={{ y: 20, delay: 0.1, start: 'top 95%' }}
class="mt-4 max-w-xs text-xl leading-[1.2] text-desc sm:max-w-140 sm:text-2xl"
>
Гайды по чтению графиков, управлению рисками и работе с ML-прогнозами.
</p>
</section>
<span
class="absolute -right-20 -bottom-40 z-0 h-80 w-full -rotate-3 rounded-full bg-primary opacity-30 blur-[200px]"
></span>
</div>
<div
use:scrollStagger={{ selector: 'a', stagger: 0.08, y: 24, start: 'top 88%' }}
class="mx-auto max-w-400 px-5 sm:px-10 lg:px-0 mb-32"
>
{#if posts.length === 0}
<div class="flex flex-col items-center gap-3 py-20 text-center">
<p class="font-mono text-sm text-desc">Статьи пока не опубликованы.</p>
</div>
{:else}
{#each posts as post, i (post.id)}
<a
href="/blog/{post.slug}"
class="group flex items-start gap-4 border-b border-bg-h py-7 no-underline transition-colors duration-150 hover:bg-bg-e sm:gap-6 sm:py-9
{i === 0 ? 'border-t border-bg-h' : ''}"
>
<span
class="w-8 shrink-0 pt-1 text-right font-mono text-[0.5625rem] tracking-widest text-desc/50"
>
{String(i + 1).padStart(2, '0')}
</span>
<div
class="relative hidden aspect-video w-40 shrink-0 overflow-hidden rounded-xl border border-bg-h bg-bg-e sm:block md:w-92"
>
<div
class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-transparent"
></div>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<h2
class="font-display text-[clamp(1.125rem,2.5vw,1.625rem)] font-black leading-[1.15] tracking-tight text-title transition-colors duration-150 group-hover:text-primary"
>
{post.title}
</h2>
<p class="line-clamp-2 text-[0.9375rem] leading-[1.6] text-desc">
{post.description}
</p>
{#if post.tags.length > 0}
<div class="mt-1 flex flex-wrap gap-1.5">
{#each post.tags.slice(0, 3) as tag, i (i)}
<span
class="rounded-sm border border-white/6 bg-white/3 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-desc/60"
>
{tag}
</span>
{/each}
</div>
{/if}
<time
datetime={post.date}
class="mt-1 font-mono text-xs tracking-[0.06em] text-desc/50"
>
{fmtDate(post.date)}
</time>
</div>
<div
class="shrink-0 pt-1 text-desc/30 transition-[color,translate] duration-700 group-hover:translate-x-2 group-hover:text-primary mr-6"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 10 H16 M12 6 L16 10 L12 14"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</a>
{/each}
{/if}
</div>
</main>
+200
View File
@@ -0,0 +1,200 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { post } = $derived(data);
function fmtDate(d: string) {
try {
return new Intl.DateTimeFormat('ru', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(d));
} catch {
return d;
}
}
</script>
<main>
<div class="mx-auto max-w-3xl px-5 pb-24 pt-16 sm:px-10 sm:pb-32 sm:pt-20 lg:px-0 mt-10">
<a
href="/blog"
class="back-link mb-6 inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M11 7 H3 M6 4 L3 7 L6 10"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Блог
</a>
<header class="mb-12 border-b border-bg-h pb-10">
{#if post.tags.length > 0}
<div class="mb-5 flex flex-wrap gap-1.5">
{#each post.tags as tag (tag)}
<span
class="rounded-sm border border-primary/25 bg-primary/10 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-primary"
>
{tag}
</span>
{/each}
</div>
{/if}
<h1
class="mb-5 font-display text-[clamp(1.875rem,5vw,2.75rem)] font-black leading-[1.08] tracking-[-0.035em] text-title"
>
{post.title}
</h1>
<p class="mb-6 text-lg leading-[1.65] text-desc">
{post.description}
</p>
<time datetime={post.date} class="font-mono text-[0.625rem] uppercase tracking-[0.08em] text-desc/50">
{fmtDate(post.date)}
</time>
</header>
<article class="prose-article">
{@html post.content}
</article>
<div class="mt-16 flex items-center justify-between gap-3 border-t border-bg-h pt-7">
<a
href="/blog"
class="back-link inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M11 7 H3 M6 4 L3 7 L6 10"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Все статьи
</a>
<a
href="/dashboard"
class="inline-flex items-center gap-2 rounded-xl bg-primary px-5 py-3 font-display text-sm font-normal transition-colors hover:bg-primary-h"
>
Открыть дашборд
<img src="/images/icons/arrow-right.svg" alt="" class="h-4 w-4" />
</a>
</div>
</div>
</main>
<style>
.back-link {
color: var(--color-desc);
opacity: 0.5;
text-decoration: none;
}
.back-link:hover {
opacity: 1;
}
.prose-article :global(h2) {
font-family: var(--font-display),sans-serif;
font-size: 1.25rem;
font-weight: 800;
color: var(--color-title);
letter-spacing: -0.025em;
line-height: 1.2;
margin-top: 2.25em;
margin-bottom: 0.75em;
}
.prose-article :global(h3) {
font-family: var(--font-display), sans-serif;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-title);
letter-spacing: -0.02em;
margin-top: 1.75em;
margin-bottom: 0.5em;
}
.prose-article :global(p) {
color: var(--color-desc);
font-size: 1rem;
line-height: 1.8;
margin-bottom: 1.25em;
}
.prose-article :global(strong) {
color: var(--color-title);
font-weight: 600;
}
.prose-article :global(a) {
color: var(--color-primary);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
transition: opacity 130ms;
}
.prose-article :global(a:hover) {
opacity: 0.75;
}
.prose-article :global(ul),
.prose-article :global(ol) {
color: var(--color-desc);
font-size: 1rem;
line-height: 1.8;
padding-left: 1.5em;
margin-bottom: 1.25em;
}
.prose-article :global(li) {
margin-bottom: 0.4em;
}
.prose-article :global(li strong) {
color: var(--color-title);
}
.prose-article :global(code) {
font-family: var(--font-mono),sans-serif;
font-size: 0.875em;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
padding: 1px 5px;
border-radius: 4px;
}
.prose-article :global(pre) {
background: var(--color-bg-e);
border: 1px solid var(--color-bg-h);
border-radius: 10px;
padding: 20px;
overflow-x: auto;
margin: 1.75em 0;
}
.prose-article :global(pre code) {
background: none;
color: var(--color-desc);
padding: 0;
font-size: 0.8125rem;
}
.prose-article :global(blockquote) {
border-left: 3px solid var(--color-primary);
padding: 4px 0 4px 20px;
margin: 1.75em 0;
color: var(--color-desc);
font-style: italic;
}
.prose-article :global(hr) {
border: none;
border-top: 1px solid var(--color-bg-h);
margin: 2.5em 0;
}
.prose-article :global(img) {
width: 100%;
border-radius: 10px;
border: 1px solid var(--color-bg-h);
margin: 1.75em 0;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { getPostBySlug } from '$lib/blog/posts';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => {
const post = getPostBySlug(params.slug);
if (!post) throw error(404, 'Статья не найдена');
return { post };
};
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { onMount } from 'svelte';
import { gsap } from 'gsap';
import { generateChartData, SYMBOLS, TIMEFRAMES, type TimeframeId } from '$lib/stores/chartStore';
import Chart from '$lib/components/Chart.svelte';
import { cn } from '$lib/utils';
let activeSymbol = $state('BTCUSDT');
let activeTimeframe = $state<TimeframeId>('5m');
let chartHeight = $state(520);
const sym = $derived(SYMBOLS.find((s) => s.id === activeSymbol) ?? SYMBOLS[0]);
const data = $derived(generateChartData(activeSymbol, activeTimeframe));
let titleEl = $state<HTMLElement>(null!);
let panelEl = $state<HTMLElement>(null!);
onMount(() => {
const update = () => {
chartHeight = window.innerWidth < 640 ? 310 : window.innerWidth < 1024 ? 450 : 590;
};
update();
window.addEventListener('resize', update);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const ctx = gsap.context(() => {
gsap.from(titleEl, { opacity: 0, y: 20, duration: 0.6, ease: 'power3.out', clearProps: 'all' });
gsap.from(panelEl, { opacity: 0, y: 32, duration: 0.7, delay: 0.12, ease: 'power3.out', clearProps: 'all' });
});
return () => { ctx.revert(); window.removeEventListener('resize', update); };
}
return () => window.removeEventListener('resize', update);
});
function fmtPrice(n: number): string {
if (n >= 10000) return n.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
if (n >= 100) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (n >= 1) return n.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 });
return n.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 });
}
function fmtPct(n: number): string {
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%';
}
</script>
<main class="min-h-screen bg-bg">
<div class="mx-auto max-w-400 px-5 pt-22 pb-16 sm:px-10 sm:pt-26 lg:pt-24">
<div bind:this={titleEl} class="mb-5 mx-auto w-fit">
<h1 class="font-display text-center text-2xl font-black uppercase leading-none tracking-tight mb-2 sm:text-4xl ">
Дашборд
</h1>
<p class="mt-1.5 font-mono text-[0.625rem] tracking-widest text-desc uppercase sm:text-xs">
ML-прогноз · Горизонт 19 свечей · Таймфрейм 5M
</p>
</div>
<div bind:this={panelEl} class="overflow-hidden rounded-2xl border border-bg-h bg-bg-c shadow-[0_40px_80px_rgba(0,0,0,0.6)]">
<div class="flex flex-col border-b border-bg-h sm:flex-row sm:items-stretch sm:justify-between">
<div class="flex overflow-hidden border-b border-bg-h sm:border-b-0 [&::-webkit-scrollbar]:hidden">
{#each SYMBOLS as s (s.id)}
<button
class={cn(
'shrink-0 border-b-2 px-3.5 py-3 font-display text-[0.6875rem] font-medium tracking-tight transition-colors duration-150 sm:px-4 sm:text-xs',
activeSymbol === s.id
? '-mb-px border-primary text-title'
: 'border-transparent text-desc hover:text-title'
)}
onclick={() => (activeSymbol = s.id)}
>
{s.short}
</button>
{/each}
</div>
<div class="flex shrink-0 items-center gap-0.5 px-3 py-2 sm:border-l sm:border-l-bg-h">
{#each TIMEFRAMES as tf (tf.id)}
<button
class={cn(
'rounded-lg px-2.5 py-1.5 font-mono text-[0.6875rem] transition-colors duration-150',
activeTimeframe === tf.id ? 'bg-bg-h text-title' : 'text-desc hover:text-title'
)}
onclick={() => (activeTimeframe = tf.id)}
>
{tf.label}
</button>
{/each}
</div>
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-bg-h px-4 py-3 sm:px-5">
<div class="flex items-baseline gap-2.5">
<span class="font-display text-[0.625rem] font-black tracking-tight text-desc uppercase hidden sm:inline">
{sym.label}
</span>
<span class="font-mono text-lg font-medium text-title sm:text-2xl">
{fmtPrice(data.currentPrice)}
</span>
<span class={cn(
'font-mono text-xs font-medium sm:text-sm',
data.change24hPct >= 0 ? 'text-emerald-400' : 'text-red-400'
)}>
{fmtPct(data.change24hPct)}
</span>
</div>
<div class={cn(
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5',
data.signal.direction === 'long' ? 'bg-emerald-400/10' : 'bg-red-400/10'
)}>
<span class={cn(
'font-display text-[0.6875rem] font-black uppercase tracking-tight',
data.signal.direction === 'long' ? 'text-emerald-400' : 'text-red-400'
)}>
{data.signal.direction === 'long' ? '▲ LONG' : '▼ SHORT'}
</span>
</div>
<div class="flex items-center gap-2">
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase hidden sm:inline">
Уверенность
</span>
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-bg-h sm:w-20">
<div
class={cn(
'h-full rounded-full transition-all duration-700',
data.signal.confidence >= 70 ? 'bg-emerald-400' : data.signal.confidence >= 55 ? 'bg-amber-400' : 'bg-red-400'
)}
style="width: {data.signal.confidence}%"
></div>
</div>
<span class="font-mono text-xs text-title">{data.signal.confidence}%</span>
</div>
<div class="ml-auto flex gap-4 sm:gap-6">
{#each [
{ label: 'Entry', val: data.signal.entry, cls: 'text-primary' },
{ label: 'TP', val: data.signal.tp, cls: 'text-emerald-400' },
{ label: 'SL', val: data.signal.sl, cls: 'text-red-400' },
] as lvl (lvl.label)}
<div class="flex flex-col gap-0.5 text-right">
<span class="font-mono text-[0.5rem] tracking-widest text-desc uppercase">{lvl.label}</span>
<span class="font-mono text-xs {lvl.cls}">{fmtPrice(lvl.val)}</span>
</div>
{/each}
</div>
</div>
<Chart data={data} decimals={sym.dec} height={chartHeight} />
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-bg-h px-4 py-3 sm:px-5">
<div class="flex items-center gap-1.5">
<div class="flex gap-0.5">
<span class="h-3.5 w-1.5 rounded-[2px] bg-emerald-400/80"></span>
<span class="h-3.5 w-1.5 rounded-[2px] bg-red-400/80"></span>
</div>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">Свечи</span>
</div>
<div class="flex items-center gap-1.5">
<svg width="22" height="4" aria-hidden="true">
<line x1="0" y1="2" x2="22" y2="2" stroke="#fe4b07" stroke-width="2" stroke-dasharray="4 3" />
</svg>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">ML-прогноз</span>
</div>
<div class="flex items-center gap-1.5">
<span class="block h-px w-4 bg-primary"></span>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">Entry</span>
</div>
<div class="flex items-center gap-1.5">
<span class="block h-px w-4" style="border-top: 2px dashed #26a69a;"></span>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">TP</span>
</div>
<div class="flex items-center gap-1.5">
<span class="block h-px w-4" style="border-top: 2px dashed #ef5350;"></span>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">SL</span>
</div>
<span class="ml-auto font-mono text-[0.5rem] tracking-widest text-desc/60 uppercase">
не является инвестиционной рекомендацией
</span>
</div>
</div>
</div>
<span
class="pointer-events-none fixed top-0 left-1/2 -translate-x-1/2 h-80 w-full max-w-3xl rounded-full bg-primary opacity-[0.07] blur-[160px]"
aria-hidden="true"
></span>
</main>
+3 -3
View File
@@ -42,7 +42,7 @@
} }
section { section {
@apply relative mx-auto max-w-400 lg:min-h-175 lg:max-h-300; @apply relative mx-auto max-w-400 lg:min-h-175 lg:max-h-300 px-10;
} }
} }
@@ -65,9 +65,9 @@
.noise { .noise {
pointer-events: none; pointer-events: none;
position: absolute; position: fixed;
inset: 0; inset: 0;
z-index: 10; z-index: 50;
background-image: url('/images/noise.png'); background-image: url('/images/noise.png');
background-repeat: repeat; background-repeat: repeat;
opacity: 0.14; opacity: 0.14;