Интерактивная визуализация спортивных коэффициентов: что удалось, а что нет

от автора

Потянул 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/