Недавно мы открыли тему нормального распределения — это один из эффектов в статистике, когда мы имеем дело со случайными событиями. В прошлый раз мы разбирали теорию и строили симуляцию на Python, а сейчас сделаем визуализацию на JavaScript с помощью демонстрации законов физики.
Если есть время, почитайте теорию и посмотрите, как мы проверили её на виртуальных бросках игральных кубиков. Если нет — вот основное, что нам сегодня понадобится:
- Закон нормального распределения — это статистический закон, который описывает, как часто различные значения случайной величины встречаются в наборе данных.
- Если очень упрощённо, закон работает так: у тебя есть какая-то масса случайных событий. У этих событий будет какое-то среднее значение — например, при броске двух кубиков минимальное значение 2, максимальное — 12, а среднее — 7. Закон нормального распределения гласит, что число 7 будет выпадать чаще всего, числа 6 и 8 — чуть пореже, числа 5 и 9 — ещё реже, числа 4 и 10 — ещё реже и так далее.
- Чаще всего результат нормального распределения представляют в виде графика: какие вещи или события случаются чаще, а какие — реже.
Вот график нормального распределения. Вершина — это среднее значение, оно наиболее вероятно. Чем больше мы отклоняемся от среднего, тем менее вероятно событие.
Про деньги. Классический пример нормального распределения — сколько люди зарабатывают на инвестициях на какой-нибудь бирже или у брокера.
Допустим, мы взяли 1 миллион человек, в среднем они заработали 3% годовых. Вот этих людей с 3% будет большинство. Тех, кто заработали 2% и 4% — чуть поменьше. Кто заработал 1% и 5% — ещё меньше. И дальше кривая распределения разбегается, и какие-то единицы заработали за год 200%, а какие-то единицы потеряли за год 200%.
Когда вам предъявляют человека, который заработал на бирже 200%, можете сразу спросить: «Окей, а какое у вас нормальное распределение?» И сразу станет понятно, что вы имеете дело с редким случаем, а большинство людей зарабатывают совсем не так, как лидеры.
Что такое доска Гальтона
Доска Гальтона — это игрушка, которая демонстрирует закон нормального распределения с помощью физических процессов.
Наверху у доски ёмкость для шариков, а под ней — столбики в шахматном порядке и дорожки, куда попадают шарики. Доска устроена так, что при каждом падении на столбик шарик с одинаковой вероятностью может упасть направо и налево. Столбиков много, поэтому мы симулируем много случайных событий. Итоговое распределение шариков по дорожкам совпадает с графиком нормального распределения.
Вот видео, где видно, как это работает. Сегодня сделаем такое же, но на JavaScript.
Логика проекта
Мы сделаем доску Гальтона в браузере — на чистом JavaScript. Для этого нам понадобится Planck — специальный движок для реализации двумерной физики в браузере.
Всё будем делать в скрипте: и рисовать доску, и прописывать логику, и создавать шарики, которые будут сыпаться сверху. В общем виде план такой:
- Создаём страницу и подключаем скрипты.
- В скрипте задаём ключевые параметры игры.
- Рисуем внешнюю сторону доски.
- Добавляем столбики и дорожки.
- Добавляем шарики.
- Подключаем клавиатуру для управления игрой.
- Собираем всё вместе и запускаем.
Вот что получится в результате:
Готовим страницу
Так как за всё будет отвечать скрипт с движком внутри, то всё, что нам нужно сделать, — это создать пустую страницу и подключить к ней два скрипта: движка и свой. Сохраняем код как 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)
])
Точно так же сделаем правую сторону — код точно такой же, просто зеркально меняются параметры:
// и сразу рисуем правую половину доски
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);
};
// высыпаем все шарики
dropBalls(BALL_NUM);
Добавляем управление с клавиатуры
Чтобы не перезагружать страницу каждый раз, когда нам понадобится запустить процесс заново, добавим управление с клавиатуры:
- при нажатии на английскую 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;
});