Потянул 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-ить часть решений, если будет интерес.