Потянул live-данные с mygameodds co, собрал real-time графики на D3.js, столкнулся с диким хаосом в структуре данных, решил через нормализацию, но провалился с адаптивом.
Цель
Построить интерактивный дашборд, визуализирующий изменение спортивных коэффициентов в реальном времени. Аналог систем мониторинга, только вместо метрик — лайв-кэфы с букмекерского API.
Архитектура
-
Источник данных: mygameodds.co
-
Стек:
-
D3.js (визуализация)
-
WebSocket (стриминг)
-
TypeScript (вся логика)
-
Vite + React (обвязка, рендер)
Работа с API
Документации к mygameodds.co не было — всё собиралось через инспекцию сети и reverse engineering.
📡 Подключение к WebSocket
const socket = new WebSocket("wss://stream.mygameodds.co/live"); socket.onmessage = (event) => { const data = JSON.parse(event.data); handleIncomingEvent(data); };
Сообщения приходят пачками, в формате:
{ "match_id": 1234, "event": "odds_update", "markets": [ { "type": "match_winner", "odds": { "home": 1.72, "draw": 3.1, "away": 4.5 } } ], "timestamp": "2025-08-03T14:22:01Z" }
Проблема — никакой стабильности. В других матчах:
-
odds = массив с ключами «name» / «value»
-
Время — только updated_at, иногда в формате Unix
-
Названия исходов («team1», «x», «team2»)
Нормализация данных
Чтобы унифицировать структуру для визуализации, написал модуль normalizeOdds(data: RawEvent): NormalizedOdds[].
🔄 Пример нормализатора
function normalizeOdds(event: RawEvent): NormalizedOdds[] { const ts = new Date(event.timestamp || event.updated_at || Date.now()).toISOString(); return event.markets.map(m => { const odds = m.odds || {}; const entries = Array.isArray(odds) ? Object.fromEntries(odds.map((o: any) => [o.name.toLowerCase(), o.value])) : odds; return { matchId: event.match_id, type: m.type, timestamp: ts, home: entries.home || entries.team1 || null, draw: entries.draw || entries.x || null, away: entries.away || entries.team2 || null, }; }); }
Выход:
{ matchId: 1234, type: 'match_winner', timestamp: '2025-08-03T14:22:01Z', home: 1.72, draw: 3.1, away: 4.5 }
Хранилище и поток данных
Сделал Map<matchId, MatchState> — храним историю коэффициентов по матчам.
При каждом odds_update пушим новые точки в массив и триггерим requestAnimationFrame на ререндер.
🧠 Простая структура:
interface MatchState { history: { timestamp: string home: number | null draw: number | null away: number | null }[] }
Визуализация на D3.js
Задачи:
-
Нарисовать три линии (home, draw, away)
-
Обновлять данные в real-time
-
Добавить зум и pan (D3 Zoom Behavior)
📈 Отрисовка графика
const svg = d3.select('#chart') .attr('width', width) .attr('height', height); const x = d3.scaleTime().range([0, width]); const y = d3.scaleLinear().range([height, 0]); const line = d3.line<OddsPoint>() .x(d => x(new Date(d.timestamp))) .y(d => y(d.home)); svg.append("path") .datum(match.history) .attr("class", "line-home") .attr("d", line);
Каждая линия рисуется отдельно (три path по одному на исход).
Обновление делаю через join().attr(«d», line) внутри RAF.
Проблемы с адаптивом
На десктопе всё круто. Но…
-
На мобилке зум ломается: пальцы срабатывают некорректно, события touchmove конфликтуют с pan
-
SVG не влезает по ширине, горизонтальный скролл не помогает
-
FPS падает при 200+ точках на графике
Рассматриваю переход на:
-
Canvas — ради производительности
-
WebGL (Pixi.js) — если графиков будет много
Что планирую дальше
-
Перевести визуализацию на Canvas
-
Сделать отложенную отрисовку (debounce + batch updates)
-
Добавить фильтры по маркету
-
Попробовать SSR для снижения TTI (если рендерить статику)
Итоги
Удалось:
✅ Протянуть live WebSocket-данные
✅ Привести хаотичные odds к единому формату
✅ Собрать интерактивный график на D3.js
✅ Показать реальные движения коэффициентов по матчам
Провалилось:
❌ Мобильный UX (зум/переходы)
❌ Нет поддержки сложных маркетов (азиатские форы, тоталы)
❌ Перформанс падает на больших выборках
P.S.
Если у кого-то был опыт переноса подобных графиков с SVG на Canvas или WebGL — поделитесь ссылками / демками / подходами. Готов open-source-ить часть решений, если будет интерес.
ссылка на оригинал статьи https://habr.com/ru/articles/933852/
Добавить комментарий