Интерактивная визуализация спортивных коэффициентов: что удалось, а что нет
- вторник, 5 августа 2025 г. в 00:00:02
 
Потянул live-данные с mygameodds co, собрал real-time графики на D3.js, столкнулся с диким хаосом в структуре данных, решил через нормализацию, но провалился с адаптивом.
Построить интерактивный дашборд, визуализирующий изменение спортивных коэффициентов в реальном времени. Аналог систем мониторинга, только вместо метрик — лайв-кэфы с букмекерского API.
Источник данных: mygameodds.co
Стек:
D3.js (визуализация)
WebSocket (стриминг)
TypeScript (вся логика)
Vite + React (обвязка, рендер)
Документации к mygameodds.co не было — всё собиралось через инспекцию сети и reverse engineering.
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
  }[]
}
Нарисовать три линии (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 (зум/переходы)
❌ Нет поддержки сложных маркетов (азиатские форы, тоталы)
❌ Перформанс падает на больших выборках
Если у кого-то был опыт переноса подобных графиков с SVG на Canvas или WebGL — поделитесь ссылками / демками / подходами. Готов open-source-ить часть решений, если будет интерес.