Крутой веб-проект: симуляция нормального распределения на JavaScript
medium

Крутой веб-проект: симуляция нормального распределения на JavaScript

Физика, статистика и математика в одном мощном проекте

Недавно мы открыли тему нормального распределения — это один из эффектов в статистике, когда мы имеем дело со случайными событиями. В прошлый раз мы разбирали теорию и строили симуляцию на Python, а сейчас сделаем визуализацию на JavaScript с помощью демонстрации законов физики. 

Если есть время, почитайте теорию и посмотрите, как мы проверили её на виртуальных бросках игральных кубиков. Если нет — вот основное, что нам сегодня понадобится:

  • Закон нормального распределения — это статистический закон, который описывает, как часто различные значения случайной величины встречаются в наборе данных.
  • Если очень упрощённо, закон работает так: у тебя есть какая-то масса случайных событий. У этих событий будет какое-то среднее значение — например, при броске двух кубиков минимальное значение 2, максимальное — 12, а среднее — 7. Закон нормального распределения гласит, что число 7 будет выпадать чаще всего, числа 6 и 8 — чуть пореже, числа 5 и 9 — ещё реже, числа 4 и 10 — ещё реже и так далее. 
  • Чаще всего результат нормального распределения представляют в виде графика: какие вещи или события случаются чаще, а какие — реже. 

Вот график нормального распределения. Вершина — это среднее значение, оно наиболее вероятно. Чем больше мы отклоняемся от среднего, тем менее вероятно событие.

Крутой веб-проект: симуляция нормального распределения на JavaScript

Про деньги. Классический пример нормального распределения — сколько люди зарабатывают на инвестициях на какой-нибудь бирже или у брокера. 

Допустим, мы взяли 1 миллион человек, в среднем они заработали 3% годовых. Вот этих людей с 3% будет большинство. Тех, кто заработали 2% и 4% — чуть поменьше. Кто заработал 1% и 5% — ещё меньше. И дальше кривая распределения разбегается, и какие-то единицы заработали за год 200%, а какие-то единицы потеряли за год 200%.

Когда вам предъявляют человека, который заработал на бирже 200%, можете сразу спросить: «Окей, а какое у вас нормальное распределение?» И сразу станет понятно, что вы имеете дело с редким случаем, а большинство людей зарабатывают совсем не так, как лидеры.

Что такое доска Гальтона

Доска Гальтона — это игрушка, которая демонстрирует закон нормального распределения с помощью физических процессов. 

Наверху у доски ёмкость для шариков, а под ней — столбики в шахматном порядке и дорожки, куда попадают шарики. Доска устроена так, что при каждом падении на столбик шарик с одинаковой вероятностью может упасть направо и налево. Столбиков много, поэтому мы симулируем много случайных событий. Итоговое распределение шариков по дорожкам совпадает с графиком нормального распределения.

Вот видео, где видно, как это работает. Сегодня сделаем такое же, но на JavaScript.

Логика проекта

Мы сделаем доску Гальтона в браузере — на чистом JavaScript. Для этого нам понадобится Planck — специальный движок для реализации двумерной физики в браузере. 

Всё будем делать в скрипте: и рисовать доску, и прописывать логику, и создавать шарики, которые будут сыпаться сверху. В общем виде план такой:

  1. Создаём страницу и подключаем скрипты.
  2. В скрипте задаём ключевые параметры игры.
  3. Рисуем внешнюю сторону доски.
  4. Добавляем столбики и дорожки.
  5. Добавляем шарики.
  6. Подключаем клавиатуру для управления игрой.
  7. Собираем всё вместе и запускаем.

Вот что получится в результате:

Крутой веб-проект: симуляция нормального распределения на JavaScript

Готовим страницу

Так как за всё будет отвечать скрипт с движком внутри, то всё, что нам нужно сделать, — это создать пустую страницу и подключить к ней два скрипта: движка и свой. Сохраняем код как index.html и открываем в браузере — будет пустая страница, которую мы наполним жизнью в скрипте:

<!DOCTYPE html>
<html lang="ru" >
<head>
  <meta charset="UTF-8">
  <title>Доска Галтона</title>
</head>
<body>
  <!-- подключаем Planck -->
  <script src='https://cdnjs.cloudflare.com/ajax/libs/planck-js/0.2.7/planck-with-testbed.min.js'></script>
  <!-- и подключаем наш скрипт -->
  <script src="script.js"></script>
</body>
</html>

Пишем скрипт

Создаём новый файл script.js, который мы подключили на странице, — теперь работать будем в нём. В этом скрипте сразу вызываем метод planck.testbed() — всё будет происходить внутри этого метода.

// весь скрипт — это один большой метод
planck.testbed("Galton Board", (testbed) => {
// тут будет код
return world;
});

Объявим все переменные, которые нам понадобятся в проекте. Прокомментировали каждую, чтобы было проще разобраться:

// размер шарика
const BALL_SIZE = 0.15;
// размер круга, на который падает шар
const NAIL_SIZE = BALL_SIZE * 4;
// ширина дорожки
const LANE_SIZE = BALL_SIZE * 6;
// количество дорожек
const LANE_NUM = 25;
// размер доски
const SIZE = LANE_SIZE * LANE_NUM * 2;
// расстояние между дорожками и шариками
const LANE_MARGIN = 1.75;
// вычисляем координаты по горизонтали
const xPos = (x, y) => {
  return (x * 2 - Math.abs(y)) * LANE_SIZE;
};
// и координаты по вертикали
const yPos = (y) => {
  return SIZE - y * LANE_SIZE * LANE_MARGIN;
};
// вычисляем верхние координаты дорожек
const LANE_TOP_YPOS = yPos(LANE_NUM);
// задаём длину дорожек
const LANE_LENGTH = SIZE / 1.6 + LANE_TOP_YPOS;
// количество шариков
const BALL_NUM = 1000;

Теперь подключим движок и настроим в нём физику, а заодно укажем, каким цветом будем рисовать доску. На странице ничего не появится нового, зато мы сможем там что-то нарисовать на следующих этапах:

// подключаем движок
const pl = planck,
Vec2 = pl.Vec2;
// создаём новый мир и добавляем гравитацию
const world = pl.World({
gravity: Vec2(0, -70)
});
// создаём виртуальную доску
const board = world.createBody();
// каким цветом будем рисовать
board.render = { fill: "#1e5f74", stroke: "#1e5f74" };

Рисуем контуры доски

Мы создали виртуальную доску и подготовили её к тому, что на ней можно рисовать новые объекты. То, что мы сейчас нарисуем, станет жёсткими стенами — внутри них и будут двигаться наши шарики. 

Стены сделаем простыми линиями — создадим контуры и пространство между ними. Размеры доски зависят от количества дорожек, размера столбиков и расстояния между ними. Сначала — левую сторону:

// рисуем левую половину доски
  board.createFixture(
    // 
    pl.Chain([
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, yPos(-3)),
      Vec2(xPos(0, -3), yPos(-3)),
      Vec2(xPos(0, -1), yPos(-1)),
      Vec2(xPos(0, 1), yPos(1)),
      Vec2(xPos(0, LANE_NUM), LANE_TOP_YPOS),
      Vec2(xPos(0, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(0, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS)
    ])
Крутой веб-проект: симуляция нормального распределения на JavaScript

Точно так же сделаем правую сторону — код точно такой же, просто зеркально меняются параметры:

// и сразу рисуем правую половину доски
  board.createFixture(
    pl.Chain([
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, yPos(-3)),
      Vec2(xPos(3, -3), yPos(-3)),
      Vec2(xPos(1, -1), yPos(-1)),
      Vec2(xPos(1, 1), yPos(1)),
      Vec2(xPos(LANE_NUM, LANE_NUM), LANE_TOP_YPOS),
      Vec2(xPos(LANE_NUM, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(LANE_NUM, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS)
    ])
  );
Крутой веб-проект: симуляция нормального распределения на JavaScript

Рисуем дорожки и столбики

Дорожки делаются просто: берём размер дорожки, её ширину и расстояние между ними и рисуем их по очереди столько, сколько записано в переменной из начала скрипта. А со столбиками интереснее — чтобы сделать их в шахматном порядке, нам понадобится два цикла: первый задаст общее количество, а второй будет увеличивать каждый раз их число в ряду на единицу, пока оно не сравняется с количеством дорожек:

// рисуем дорожки
for (let x = 1; x < LANE_NUM; x++) {
  // 
  board.createFixture(
    pl.Chain([
      Vec2(xPos(x, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH),
      Vec2(xPos(x, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(x, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(x, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH)
    ])
  );
}
// рисуем столбики, которые задают направления шарам
for (let y = 1; y <= LANE_NUM; y++) {
  // рисуем их в шахматном порядке
  for (let x = 0; x <= Math.abs(y); x++) {
    // 
    board.createFixture(pl.Circle(Vec2(xPos(x, y), yPos(y)), NAIL_SIZE));
  }
}
Крутой веб-проект: симуляция нормального распределения на JavaScript

Создаём шарики

Каждый шарик — это объект, который мы создадим в виртуальном двумерном мире. У этого объекта есть три важных для нас параметра: плотность, сила трения и прыгучесть. Нам достаточно задать эти параметры и попросить движок сделать эти объекты в форме круга, а всё остальное движок сделает сам.

Высыпание шариков сделаем так: сначала опишем высыпание на доску одного шара, а потом в цикле с таймером высыпем все шары. Единственный момент, который нужно будет учесть, — каждый шарик обрабатывается независимо от других, поэтому в середине высыпания картинка может немного тормозить. Это значит, что движку не хватает ресурсов, чтобы нарисовать столько объектов без рывков и задержек. Физика будет работать, но картинка иногда тормозить — это нормально.

// переменная для таймера
let dropBallsId = 0;
// массив с шариками
let balls = [];
// функция выбрасывания одного шарика на доску
const dropBall = () => {
  // создаём шар
  const ball = world.createDynamicBody({
    // задаём случайное положение в воронке
    position: Vec2(xPos(2, -2) * 2 * Math.random() - xPos(2, -2), yPos(-2))
  });
  // рисуем шар
  ball.render = { fill: "#fcdab7", stroke: "#1e5f74" };
  // описываем внутренние свойства шара — плотность, силу трения и прыгучесть
  ball.createFixture(pl.Circle(BALL_SIZE), {
    density: 1000.0,
    friction: 10,
    restitution: 0.3
  });
  // добавляем очередной шар
  balls.push(ball);
};
// высыпаем все шары по очереди
const dropBalls = (ballNum) => {
  // высыпаем шары с определённым интервалом
  dropBallsId = setInterval(() => {
    // бросаем шар
    dropBall();
    // уменьшаем количество шаров
    ballNum--;
    // если шаров не осталось — останавливаемся
    if (ballNum <= 0) {
      clearInterval(dropBallsId);
    }
  }, 200);
};
// высыпаем все шарики
dropBalls(BALL_NUM);
Крутой веб-проект: симуляция нормального распределения на JavaScript

Добавляем управление с клавиатуры

Чтобы не перезагружать страницу каждый раз, когда нам понадобится запустить процесс заново, добавим управление с клавиатуры:

  • при нажатии на английскую X очищаем и перезапускаем все шары;
  • при нажатии на D — бросаем один шарик;
  • при нажатии на C — очищаем доску.

Для этого нам понадобится вспомогательная функция clearBalls — она уберёт все шарики из виртуального мира и вернёт вспомогательные переменные в исходное состояние.

Это последнее, что нам осталось сделать в проекте, — добавляем это в основной метод и смотрим, как распределяются шарики по дорожкам.

// убираем все шарики
const clearBalls = () => {
  balls.forEach((ball) => {
    // убираем их из виртуального мира
    world.destroyBody(ball);
  });
  // ощищаем массив с шариками
  balls = [];
  // останавливаем высыпание шаров с определённым интервалом по времени
  clearInterval(dropBallsId);
};

// добавляем реакцию на нажатия клавиш
testbed.keydown = (code, char) => {
  // смотрим, какая клавиша нажата
  switch (char) {
    // если X — перезапускаем все шары
    case "X":
      clearBalls();
      dropBalls(BALL_NUM);
      break;
    // если D — роняем один шарик
    case "D":
      dropBall();
      break;
    // если C — очищаем доску
    case "C":
      clearBalls();
      break;
  }
};

Посмотреть на доску Гальтона на странице проекта

// весь скрипт — это один большой метод
planck.testbed("Galton Board", (testbed) => {
  // размер шарика
  const BALL_SIZE = 0.15;
  // размер круга, на который падает шар
  const NAIL_SIZE = BALL_SIZE * 4;
  // ширина дорожки
  const LANE_SIZE = BALL_SIZE * 6;
  // количество дорожек
  const LANE_NUM = 25;
  // размер доски
  const SIZE = LANE_SIZE * LANE_NUM * 2;
  // расстояние между дорожками и шариками
  const LANE_MARGIN = 1.75;
  // вычисляем координаты по горизонтали
  const xPos = (x, y) => {
    return (x * 2 - Math.abs(y)) * LANE_SIZE;
  };
  // и координаты по вертикали
  const yPos = (y) => {
    return SIZE - y * LANE_SIZE * LANE_MARGIN;
  };
  // вычисляем верхние координаты дорожек
  const LANE_TOP_YPOS = yPos(LANE_NUM);
  // задаём длину дорожек
  const LANE_LENGTH = SIZE / 1.6 + LANE_TOP_YPOS;
  // количество шариков
  const BALL_NUM = 1000;
  // подключаем движок
  const pl = planck,
    Vec2 = pl.Vec2;
  // создаём новый мир и добавляем гравитацию
  const world = pl.World({
    gravity: Vec2(0, -70)
  });
  // создаём виртуальную доску
  const board = world.createBody();
  // каким цветом будем рисовать
  board.render = { fill: "#1e5f74", stroke: "#1e5f74" };
  // рисуем левую половину доски
  board.createFixture(
    // 
    pl.Chain([
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, yPos(-3)),
      Vec2(xPos(0, -3), yPos(-3)),
      Vec2(xPos(0, -1), yPos(-1)),
      Vec2(xPos(0, 1), yPos(1)),
      Vec2(xPos(0, LANE_NUM), LANE_TOP_YPOS),
      Vec2(xPos(0, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(0, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(0, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS)
    ])
  );
  // и сразу рисуем правую половину доски
  board.createFixture(
    pl.Chain([
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, yPos(-3)),
      Vec2(xPos(3, -3), yPos(-3)),
      Vec2(xPos(1, -1), yPos(-1)),
      Vec2(xPos(1, 1), yPos(1)),
      Vec2(xPos(LANE_NUM, LANE_NUM), LANE_TOP_YPOS),
      Vec2(xPos(LANE_NUM, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS),
      Vec2(xPos(LANE_NUM, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH + NAIL_SIZE),
      Vec2(0, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH - NAIL_SIZE),
      Vec2(xPos(LANE_NUM, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS)
    ])
  );
  // рисуем дорожки
  for (let x = 1; x < LANE_NUM; x++) {
    // 
    board.createFixture(
      pl.Chain([
        Vec2(xPos(x, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH),
        Vec2(xPos(x, LANE_NUM) - NAIL_SIZE, LANE_TOP_YPOS),
        Vec2(xPos(x, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS),
        Vec2(xPos(x, LANE_NUM) + NAIL_SIZE, LANE_TOP_YPOS - LANE_LENGTH)
      ])
    );
  }
  // рисуем столбики, которые задают направления шарам
  for (let y = 1; y <= LANE_NUM; y++) {
    // рисуем их в шахматном порядке
    for (let x = 0; x <= Math.abs(y); x++) {
      // 
      board.createFixture(pl.Circle(Vec2(xPos(x, y), yPos(y)), NAIL_SIZE));
    }
  }
  
  // переменная для таймера
  let dropBallsId = 0;
  // массив с шариками
  let balls = [];
  // функция выбрасывания одного шарика на доску
  const dropBall = () => {
    // создаём шар
    const ball = world.createDynamicBody({
      // задаём случайное положение в воронке
      position: Vec2(xPos(2, -2) * 2 * Math.random() - xPos(2, -2), yPos(-2))
    });
    // рисуем шар
    ball.render = { fill: "#fcdab7", stroke: "#1e5f74" };
    // описываем внутренние свойства шара — плотность, силу трения и прыгучесть
    ball.createFixture(pl.Circle(BALL_SIZE), {
      density: 1000.0,
      friction: 10,
      restitution: 0.3
    });
    // добавляем очередной шар
    balls.push(ball);
  };
  // высыпаем все шары по очереди
  const dropBalls = (ballNum) => {
    // высыпаем шары с определённым интервалом
    dropBallsId = setInterval(() => {
      // бросаем шар
      dropBall();
      // уменьшаем количество шаров
      ballNum--;
      // если шаров не осталось — останавливаемся
      if (ballNum <= 0) {
        clearInterval(dropBallsId);
      }
    }, 200);
  };
  // убираем все шарики
  const clearBalls = () => {
    balls.forEach((ball) => {
      // убираем их из виртуального мира
      world.destroyBody(ball);
    });
    // ощищаем массив с шариками
    balls = [];
    // останавливаем высыпание шаров с определённым интервалом по времени
    clearInterval(dropBallsId);
  };

  // добавляем реакцию на нажатия клавиш
  testbed.keydown = (code, char) => {
    // смотрим, какая клавиша нажата
    switch (char) {
      // если X — перезапускаем все шары
      case "X":
        clearBalls();
        dropBalls(BALL_NUM);
        break;
      // если D — роняем один шарик
      case "D":
        dropBall();
        break;
      // если C — очищаем доску
      case "C":
        clearBalls();
        break;
    }
  };
  // высыпаем все шарики
  dropBalls(BALL_NUM);
  // возвращаем, что получилось
  return world;
});

Веб-разработка — это новый чёрный
На базе веб-технологий делают всё — от сложного софта до высокобюджетных игр. Изучите технологии и начните карьеру в ИТ. Старт бесплатно. Попробуйте, вдруг вам понравится.
Начать бесплатно
Веб-разработка — это новый чёрный
Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Начать карьеру в ИТ
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
medium