+7(495)772-64-67

``html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />

<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>

<title>OLED Particle Clock</title>

<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000000;
font-family: system-ui, -apple-system, sans-serif;

-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}

#canvas {
touch-action: none;
-webkit-user-select: none;
user-select: none;
display: block;
width: 100vw;
height: 100vh;
}

#hint {
position: absolute;
bottom: 8vh;
left: 50%;
transform: translateX(-50%);

color: rgba(150, 180, 220, 0.4);
font-size: 3.5vw;

user-select: none;
pointer-events: none;

z-index: 10;

transition: opacity 1s;

white-space: nowrap;
}
</style>
</head>

<body>

<canvas id="canvas"></canvas>

<div id="hint">
Нажмите, чтобы увидеть время
</div>


<script>

/* =========================================================
CANVAS
========================================================= */

const canvas =
document.getElementById('canvas');

const ctx =
canvas.getContext('2d');

const hint =
document.getElementById('hint');


const textCanvas =
document.createElement('canvas');

const textCtx =
textCanvas.getContext('2d');


let W = 0;
let H = 0;
let dpr = 1;


function resize() {

dpr =
Math.min(
window.devicePixelRatio || 1,
2
);

W =
window.innerWidth;

H =
window.innerHeight;


canvas.width =
W * dpr;

canvas.height =
H * dpr;


canvas.style.width =
W + 'px';

canvas.style.height =
H + 'px';


ctx.setTransform(
dpr,
0,
0,
dpr,
0,
0
);
}


window.addEventListener(
'resize',
resize
);

resize();


/* =========================================================
РЕЖИМЫ
========================================================= */

const MODE_CHAOS =
'chaos';

const MODE_FORMING =
'forming';

const MODE_HOLDING =
'holding';

const MODE_RELEASING =
'releasing';

const MODE_WEATHER_FORMING =
'weather_forming';

const MODE_WEATHER =
'weather';


let mode =
MODE_CHAOS;


let holdTimer = 0;


const HOLD_DURATION =
4000;

const FORM_DURATION =
1000;

const RELEASE_DURATION =
4500;

const WEATHER_FORM_DURATION =
1300;


const FORM_SPEED =
0.04;

const WEATHER_FORM_SPEED =
0.032;

const RELEASE_SPEED =
0.02;


/* =========================================================
ЧАСТИЦЫ
========================================================= */

const PARTICLE_COUNT =
600;

const particles = [];


class Particle {

constructor() {

this.x =
Math.random() * W;

this.y =
Math.random() * H;


this.vx =
(Math.random() - 0.5) * 1.5;

this.vy =
(Math.random() - 0.5) * 1.5;


this.size =
Math.random() * 2 + 0.8;


const hue =
Math.random() * 50 + 180;


this.color =
`hsl(${hue}, 80%, 65%)`;


this.tx = null;
this.ty = null;
this.alpha = 1;
}


update() {

/* -----------------------------------------------------
ХАОС И РАСПАД
----------------------------------------------------- */

if (mode === MODE_RELEASING) {
const dx = this.x - W / 2;
const dy = this.y - H / 2;
const len = Math.hypot(dx, dy) || 1;

// Медленное плавное рассеивание наружу.
this.vx = this.vx * 0.985 + (dx / len) * 0.16;
this.vy = this.vy * 0.985 + (dy / len) * 0.16;

this.x += this.vx;
this.y += this.vy;

if (
this.x < -120 || this.x > W + 120 ||
this.y < -120 || this.y > H + 120
) {
this.alpha = 0;
}

return;
}

if (mode === MODE_CHAOS) {
this.x += this.vx;
this.y += this.vy;

if (this.x < 0 || this.x > W) this.vx *= -1;
if (this.y < 0 || this.y > H) this.vy *= -1;

return;
}


/* -----------------------------------------------------
ЧАСЫ
----------------------------------------------------- */

if (
mode === MODE_FORMING ||
mode === MODE_HOLDING
) {

if (
this.tx !== null &&
this.ty !== null
) {

this.x +=
(this.tx - this.x) *
FORM_SPEED;


this.y +=
(this.ty - this.y) *
FORM_SPEED;


if (
mode === MODE_HOLDING
) {

this.x +=
(Math.random() - 0.5) *
0.8;

this.y +=
(Math.random() - 0.5) *
0.8;
}
}


return;
}


/* -----------------------------------------------------
ПОГОДА
----------------------------------------------------- */

if (
mode === MODE_WEATHER_FORMING ||
mode === MODE_WEATHER
) {

if (
this.tx !== null &&
this.ty !== null
) {

this.x +=
(this.tx - this.x) *
WEATHER_FORM_SPEED;


this.y +=
(this.ty - this.y) *
WEATHER_FORM_SPEED;


if (
mode === MODE_WEATHER
) {

this.x +=
(Math.random() - 0.5) *
0.25;

this.y +=
(Math.random() - 0.5) *
0.25;
}
}
}
}


draw() {
if (this.alpha <= 0) return;

ctx.globalAlpha = this.alpha;
ctx.fillStyle = this.color;

ctx.beginPath();
ctx.arc(
this.x,
this.y,
this.size,
0,
Math.PI * 2
);
ctx.fill();
ctx.globalAlpha = 1;
}
}


for (
let i = 0;
i < PARTICLE_COUNT;
i++
) {

particles.push(
new Particle()
);
}


/* =========================================================
ГЕНЕРАЦИЯ ТОЧЕК ИЗ ТЕКСТА
========================================================= */

function generateTextPoints(
text,
fontScale
) {

textCanvas.width =
W;

textCanvas.height =
H;


textCtx.fillStyle =
'#000';

textCtx.fillRect(
0,
0,
W,
H
);


const fontSize =
Math.floor(
W * fontScale
);


textCtx.font =
`bold ${fontSize}px system-ui, -apple-system, sans-serif`;


textCtx.fillStyle =
'#fff';


textCtx.textAlign =
'center';

textCtx.textBaseline =
'middle';


textCtx.fillText(
text,
W / 2,
H / 2
);


const image =
textCtx.getImageData(
0,
0,
W,
H
);


const data =
image.data;


const points = [];

const step = 4;


for (
let y = 0;
y < H;
y += step
) {

for (
let x = 0;
x < W;
x += step
) {

const index =
(y * W + x) * 4;


if (
data[index] > 128
) {

points.push({
x,
y
});
}
}
}


return points;
}


/* =========================================================
НАЗНАЧЕНИЕ ЦЕЛЕЙ
========================================================= */

function spawnParticleOutside(p, tx, ty) {
const side = Math.floor(Math.random() * 4);
const margin = 100 + Math.random() * 180;

if (side === 0) {
p.x = -margin;
p.y = ty + (Math.random() - 0.5) * H * 0.9;
} else if (side === 1) {
p.x = W + margin;
p.y = ty + (Math.random() - 0.5) * H * 0.9;
} else if (side === 2) {
p.x = tx + (Math.random() - 0.5) * W * 0.9;
p.y = -margin;
} else {
p.x = tx + (Math.random() - 0.5) * W * 0.9;
p.y = H + margin;
}

p.vx = 0;
p.vy = 0;
p.alpha = 1;
}

function assignTargets(
points,
fromOutside = false
) {

if (
!points ||
points.length === 0
) {
return;
}

const shuffled =
points.slice();

for (
let i = shuffled.length - 1;
i > 0;
i--
) {

const j =
Math.floor(
Math.random() * (i + 1)
);

[
shuffled[i],
shuffled[j]
] =
[
shuffled[j],
shuffled[i]
];
}

for (
let i = 0;
i < particles.length;
i++
) {

const target =
shuffled[
i % shuffled.length
];

particles[i].tx =
target.x +
(Math.random() - 0.5) * 3;

particles[i].ty =
target.y +
(Math.random() - 0.5) * 3;

if (fromOutside) {
spawnParticleOutside(
particles[i],
particles[i].tx,
particles[i].ty
);
}
}
}


/* =========================================================
ЧАСЫ
========================================================= */

let clockPoints = [];

let lastClockText = '';


function getClockText() {

const now =
new Date();


return (
String(
now.getHours()
).padStart(2, '0')
+
':'
+
String(
now.getMinutes()
).padStart(2, '0')
);
}


function showClock() {

weatherRequestId++;
weatherArtReleasing = false;
clearWeatherArt();

const text =
getClockText();


clockPoints =
generateTextPoints(
text,
0.22
);


lastClockText =
text;


assignTargets(
clockPoints
);

particles.forEach(p => { p.alpha = 1; });

mode =
MODE_FORMING;


holdTimer =
0;


hint.style.opacity =
'0';
}


/* =========================================================
ПОГОДА
========================================================= */

let weather = {

temperature: null,

apparentTemperature: null,

code: 0,

wind: 0,

description: ''

};


let weatherPoints = [];

let weatherParticles = [];


let weatherLastUpdate =
0;


const WEATHER_CACHE_TIME =
10 * 60 * 1000;


/* =========================================================
ОПИСАНИЕ ПОГОДЫ
========================================================= */

function weatherDescription(
code
) {

if (code === 0)
return 'Ясно';

if (code === 1)
return 'Преимущественно ясно';

if (code === 2)
return 'Переменная облачность';

if (code === 3)
return 'Пасмурно';

if (
code === 45 ||
code === 48
)
return 'Туман';

if (
[51, 53, 55, 56, 57]
.includes(code)
)
return 'Морось';

if (
[61, 63, 65, 66, 67]
.includes(code)
)
return 'Дождь';

if (
[71, 73, 75, 77, 85, 86]
.includes(code)
)
return 'Снег';

if (
[80, 81, 82]
.includes(code)
)
return 'Ливень';

if (
[95, 96, 99]
.includes(code)
)
return 'Гроза';

return 'Погода';
}


/* =========================================================
ТИП ПОГОДЫ
========================================================= */

function getWeatherType(
code
) {

if (
code === 0 ||
code === 1
)
return 'sun';


if (
code === 2 ||
code === 3
)
return 'cloud';


if (
code === 45 ||
code === 48
)
return 'fog';


if (
[
51, 53, 55,
56, 57,
61, 63, 65,
66, 67,
80, 81, 82
].includes(code)
)
return 'rain';


if (
[
71, 73, 75,
77, 85, 86
].includes(code)
)
return 'snow';


if (
[95, 96, 99].includes(code)
)
return 'storm';


return 'cloud';
}


/* =========================================================
ЗАПРОС ПОГОДЫ
========================================================= */

async function loadWeather() {
const fetchWeather = async (lat, lon, timezone = 'auto') => {
const url =
'https://api.open-meteo.com/v1/forecast' +
`?latitude=${lat}&longitude=${lon}` +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m' +
`&timezone=${timezone}`;

const response = await fetch(url);
if (!response.ok) throw new Error('Weather request failed');

const data = await response.json();
if (!data.current) throw new Error('Weather data is missing');

weather.temperature = Math.round(data.current.temperature_2m);
weather.apparentTemperature =
Math.round(data.current.apparent_temperature);
weather.code = data.current.weather_code;
weather.wind = Math.round(data.current.wind_speed_10m);
weather.description = weatherDescription(weather.code);
weatherLastUpdate = Date.now();

return true;
};

// Сначала гарантированно получаем погоду Москвы.
// Это не зависит от разрешения GPS.
try {
await fetchWeather(55.7558, 37.6173, 'Europe/Moscow');
} catch (e) {
console.warn('Moscow weather failed:', e);
return false;
}

// Затем, если GPS доступен, пытаемся заменить её локальной погодой.
if (navigator.geolocation) {
try {
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: false,
timeout: 3000,
maximumAge: 10 * 60 * 1000
});
});

try {
await fetchWeather(
position.coords.latitude,
position.coords.longitude
);
} catch (e) {
console.warn('Local weather failed, keeping Moscow:', e);
}
} catch (e) {
console.warn('Geolocation unavailable, keeping Moscow:', e);
}
}

return weather.temperature !== null;
}

function prepareWeatherText() {

if (
weather.temperature === null
) {

return;
}


let temperature =
weather.temperature;


let text;


if (
temperature > 0
) {

text =
`+${temperature}°`;

} else {

text =
`${temperature}°`;
}


/*
Температура чуть меньше часов,
чтобы вокруг неё оставалась
зона для анимации.
*/

weatherPoints =
generateTextPoints(
text,
0.20
);


assignTargets(
weatherPoints,
true
);
}


/* =========================================================
ЧАСТИЦЫ ПОГОДЫ
========================================================= */

function createWeatherParticles() {

weatherParticles = [];


const type =
getWeatherType(
weather.code
);


/* -------------------------------------------------------
ДОЖДЬ
------------------------------------------------------- */

if (
type === 'rain' ||
type === 'storm'
) {

const count =
Math.min(
260,
Math.floor(
W * H / 3600
)
);


for (
let i = 0;
i < count;
i++
) {

weatherParticles.push({

x:
Math.random() * W,

y:
Math.random() * H,

speed:
7 + Math.random() * 10,

length:
8 + Math.random() * 15,

alpha:
0.15 +
Math.random() * 0.5
});
}
}


/* -------------------------------------------------------
СНЕГ
------------------------------------------------------- */

if (
type === 'snow'
) {

const count =
Math.min(
180,
Math.floor(
W * H / 4800
)
);


for (
let i = 0;
i < count;
i++
) {

weatherParticles.push({

x:
Math.random() * W,

y:
Math.random() * H,

speed:
0.4 +
Math.random() * 1.4,

radius:
1 +
Math.random() * 2.5,

phase:
Math.random() *
Math.PI * 2,

alpha:
0.3 +
Math.random() * 0.6
});
}
}


/* -------------------------------------------------------
ТУМАН
------------------------------------------------------- */

if (
type === 'fog'
) {

for (
let i = 0;
i < 35;
i++
) {

weatherParticles.push({

x:
Math.random() * W,

y:
Math.random() * H,

radius:
30 +
Math.random() * 80,

speed:
0.1 +
Math.random() * 0.3,

phase:
Math.random() * Math.PI * 2,

alpha:
0.015 +
Math.random() * 0.035
});
}
}
}


/* =========================================================
ПОГОДА — ПОЛНОСТЬЮ ИЗ ЧАСТИЦ
========================================================= */

let weatherArt = [];

function clearWeatherArt() {
weatherArt = [];
}

let weatherArtReleasing = false;

function releaseWeatherArt() {
weatherArtReleasing = true;

weatherArt.forEach(p => {
const dx = p.x - W / 2;
const dy = p.y - H / 2;
const len = Math.hypot(dx, dy) || 1;

p.vx = (dx / len) * (1.25 + Math.random() * 0.85);
p.vy = (dy / len) * (1.25 + Math.random() * 0.85);
});
}

function addWeatherParticle(x, y, vx, vy, size, alpha) {
const tx = x;
const ty = y;

const side = Math.floor(Math.random() * 4);
const margin = 100 + Math.random() * 220;

if (side === 0) {
x = -margin;
y = ty + (Math.random() - 0.5) * H;
} else if (side === 1) {
x = W + margin;
y = ty + (Math.random() - 0.5) * H;
} else if (side === 2) {
x = tx + (Math.random() - 0.5) * W;
y = -margin;
} else {
x = tx + (Math.random() - 0.5) * W;
y = H + margin;
}

weatherArt.push({
x, y, tx, ty, vx, vy, size, alpha,
baseAlpha: alpha,
phase: Math.random() * Math.PI * 2
});
}

function particleCircle(cx, cy, radius, count, size, alpha) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const r = radius + (Math.random() - 0.5) * 3;

addWeatherParticle(
cx + Math.cos(a) * r,
cy + Math.sin(a) * r,
0, 0, size, alpha
);
}
}

function particleCloud(cx, cy, scale, count, alpha) {
const blobs = [
[-48, 8, 30],
[-18, -10, 40],
[20, -20, 48],
[58, 5, 32]
];

for (let i = 0; i < count; i++) {
const b = blobs[i % blobs.length];
const a = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * b[2] * scale;

addWeatherParticle(
cx + b[0] * scale + Math.cos(a) * r,
cy + b[1] * scale + Math.sin(a) * r,
0.10 + Math.random() * 0.16,
0,
0.8 + Math.random() * 1.3,
alpha
);
}
}

function createParticleWeather() {
clearWeatherArt();

const type = getWeatherType(weather.code);

/* ЯСНО: солнце и вращающийся ореол. */
if (type === 'sun') {
const x = W * 0.18;
const y = H * 0.20;
const r = Math.min(W, H) * 0.06;

particleCircle(x, y, r, 100, 1.5, 0.70);

for (let i = 0; i < 40; i++) {
const a = i * Math.PI * 2 / 40;

for (let j = 0; j < 4; j++) {
const d = r * (1.45 + j * 0.18);

addWeatherParticle(
x + Math.cos(a) * d,
y + Math.sin(a) * d,
0, 0, 1.1, 0.35
);
}
}
}

/* ОБЛАЧНО: облака полностью из точек. */
if (type === 'cloud') {
particleCloud(W * 0.25, H * 0.21, 1.0, 190, 0.45);
particleCloud(W * 0.72, H * 0.35, 0.72, 120, 0.28);
}

/* ДОЖДЬ / ГРОЗА: каждая капля — частица. */
if (type === 'rain' || type === 'storm') {
const count = Math.min(340, Math.floor(W * H / 2800));

for (let i = 0; i < count; i++) {
addWeatherParticle(
Math.random() * W,
Math.random() * H,
-0.7,
5 + Math.random() * 7,
0.8 + Math.random() * 0.8,
0.30 + Math.random() * 0.50
);
}

particleCloud(W * 0.50, H * 0.10, 1.0, 170, 0.28);

/* Молния тоже состоит только из частиц. */
if (type === 'storm') {
const points = [
[W * 0.72, H * 0.13],
[W * 0.68, H * 0.23],
[W * 0.72, H * 0.23],
[W * 0.64, H * 0.38]
];

for (let i = 0; i < points.length - 1; i++) {
const [x1, y1] = points[i];
const [x2, y2] = points[i + 1];

for (let j = 0; j < 14; j++) {
const t = j / 14;

addWeatherParticle(
x1 + (x2 - x1) * t,
y1 + (y2 - y1) * t,
0, 0, 1.8, 0.75
);
}
}
}
}

/* СНЕГ: снежинки — частицы. */
if (type === 'snow') {
const count = Math.min(250, Math.floor(W * H / 4000));

for (let i = 0; i < count; i++) {
addWeatherParticle(
Math.random() * W,
Math.random() * H,
0,
0.35 + Math.random() * 1.2,
1.0 + Math.random() * 2.0,
0.35 + Math.random() * 0.55
);
}

particleCloud(W * 0.30, H * 0.12, 0.9, 130, 0.28);
}

/* ТУМАН: медленно плывущие крупные частицы. */
if (type === 'fog') {
for (let i = 0; i < 100; i++) {
addWeatherParticle(
Math.random() * W,
H * (0.25 + Math.random() * 0.5),
0.12 + Math.random() * 0.30,
0,
2 + Math.random() * 3,
0.05 + Math.random() * 0.10
);
}
}
}

function drawWeatherArt(time) {
const type = getWeatherType(weather.code);

for (const p of weatherArt) {

if (!weatherArtReleasing && mode === MODE_WEATHER_FORMING) {
p.x += (p.tx - p.x) * 0.030;
p.y += (p.ty - p.y) * 0.030;

const distance =
Math.hypot(
p.tx - p.x,
p.ty - p.y
);

p.alpha = Math.min(
p.baseAlpha,
Math.max(
0,
1 - distance / Math.max(W, H)
)
);

if (distance < 35) {
p.alpha = p.baseAlpha;
}
}

if (weatherArtReleasing) {
p.x += p.vx;
p.y += p.vy;
p.vx *= 1.012;
p.vy *= 1.012;
p.alpha *= 0.985;

if (
p.x < -80 || p.x > W + 80 ||
p.y < -80 || p.y > H + 80
) {
p.alpha = 0;
}

if (p.alpha <= 0.01) continue;

ctx.fillStyle = `rgba(175,210,245,${p.alpha})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
continue;
}

if (type === 'rain' || type === 'storm') {
p.x += p.vx;
p.y += p.vy;

if (p.y > H + 20) {
p.y = -20;
p.x = Math.random() * W;
}
}

if (type === 'snow') {
p.y += p.vy;
p.x += Math.sin(time * 0.001 + p.phase) * 0.45;

if (p.y > H + 10) {
p.y = -10;
p.x = Math.random() * W;
}
}

if (type === 'cloud') {
p.x += p.vx;

if (p.x > W + 100) {
p.x = -100;
}
}

if (type === 'fog') {
p.x += p.vx;
p.y += Math.sin(time * 0.0007 + p.phase) * 0.12;

if (p.x > W + 20) {
p.x = -20;
}
}

/* Вращаем солнечные частицы вокруг центра солнца. */
if (type === 'sun') {
const cx = W * 0.18;
const cy = H * 0.20;
const dx = p.x - cx;
const dy = p.y - cy;
const radius = Math.hypot(dx, dy);

if (radius > 0) {
const a = Math.atan2(dy, dx) + 0.0007;

p.x = cx + Math.cos(a) * radius;
p.y = cy + Math.sin(a) * radius;
}
}

/* Центральная зона всегда свободна для температуры. */
if (
!weatherArtReleasing &&
Math.abs(p.x - W / 2) < W * 0.34 &&
Math.abs(p.y - H / 2) < H * 0.19
) {
continue;
}

const flicker =
0.72 +
Math.sin(time * 0.002 + p.phase) * 0.28;

ctx.fillStyle =
`rgba(175,210,245,${p.alpha * flicker})`;

ctx.beginPath();
ctx.arc(
p.x,
p.y,
p.size,
0,
Math.PI * 2
);
ctx.fill();
}

/* Вспышка грозы — увеличиваем яркость самих частиц. */
if (type === 'storm') {
const flash = Math.sin(time * 0.0047);

if (flash > 0.997) {
ctx.fillStyle = 'rgba(220,235,255,0.08)';
ctx.fillRect(0, 0, W, H);
}
}
}

/* =========================================================
ТЕКСТ ПОД ТЕМПЕРАТУРОЙ
========================================================= */

function drawWeatherInfo() {

if (
weather.temperature === null
) {

return;
}


ctx.save();


ctx.textAlign =
'center';

ctx.textBaseline =
'middle';


/*
Описание.
*/

ctx.font =
`500 ${Math.floor(W * 0.038)}px system-ui, sans-serif`;


ctx.fillStyle =
'rgba(160,190,220,0.55)';


ctx.fillText(
weather.description,
W / 2,
H * 0.69
);


/*
Ощущаемая температура + ветер.
*/

ctx.font =
`400 ${Math.floor(W * 0.026)}px system-ui, sans-serif`;


ctx.fillStyle =
'rgba(150,180,220,0.30)';


ctx.fillText(
`Ощущается ${weather.apparentTemperature}° • ветер ${weather.wind} км/ч`,
W / 2,
H * 0.75
);


ctx.restore();
}


/* =========================================================
ПОКАЗ ПОГОДЫ
========================================================= */

let weatherRequestId = 0;

function showWeather() {
const requestId = ++weatherRequestId;

mode = MODE_WEATHER_FORMING;
holdTimer = 0;
hint.style.opacity = '0';

weatherArtReleasing = false;
clearWeatherArt();

particles.forEach(p => {
p.tx = null;
p.ty = null;
});

weather.temperature = null;
weather.description = 'Загрузка…';

weatherPoints = generateTextPoints('…', 0.20);
assignTargets(weatherPoints, true);
createLoadingWeatherParticles();

loadWeather()
.then(success => {
if (
requestId !== weatherRequestId ||
(mode !== MODE_WEATHER_FORMING && mode !== MODE_WEATHER)
) return;

if (success && weather.temperature !== null) {
prepareWeatherText();
createParticleWeather();
} else {
weatherPoints = generateTextPoints('--°', 0.20);
assignTargets(weatherPoints);
weather.description = 'Нет данных';
clearWeatherArt();
}
})
.catch(error => {
console.warn('Weather error:', error);

if (
requestId !== weatherRequestId ||
(mode !== MODE_WEATHER_FORMING && mode !== MODE_WEATHER)
) return;

weatherPoints = generateTextPoints('--°', 0.20);
assignTargets(weatherPoints);
weather.description = 'Нет данных';
clearWeatherArt();
});
}

function createLoadingWeatherParticles() {
clearWeatherArt();

for (let i = 0; i < 160; i++) {
addWeatherParticle(
Math.random() * W,
Math.random() * H,
(Math.random() - 0.5) * 0.25,
(Math.random() - 0.5) * 0.25,
0.8 + Math.random() * 1.2,
0.18 + Math.random() * 0.20
);
}
}

function exitWeather() {
weatherRequestId++;
releaseWeatherArt();

mode = MODE_RELEASING;
holdTimer = 0;

particles.forEach(p => {
const dx = p.x - W / 2;
const dy = p.y - H / 2;
const len = Math.hypot(dx, dy) || 1;

p.tx = null;
p.ty = null;
p.vx = (dx / len) * (0.52 + Math.random() * 0.48);
p.vy = (dy / len) * (0.52 + Math.random() * 0.48);
p.alpha = 1;
});
}


function handleSingleTap() {

if (
mode === MODE_WEATHER ||
mode === MODE_WEATHER_FORMING
) {

exitWeather();

return;
}


if (
mode === MODE_CHAOS ||
mode === MODE_RELEASING
) {

showClock();
}
}


/* =========================================================
ДВОЙНОЙ ТАП
========================================================= */

let tapTimeout =
null;

let tapCount =
0;


const DOUBLE_TAP_DELAY =
300;


/*
ВАЖНО:

Используем только pointerup.

Никаких click + touchstart +
touchend одновременно.
*/

canvas.addEventListener(
'pointerup',
function (event) {
if (event.pointerType === 'mouse') return;

event.preventDefault();

tapCount++;

if (tapCount === 2) {
clearTimeout(tapTimeout);
tapTimeout = null;
tapCount = 0;

if (
mode !== MODE_WEATHER &&
mode !== MODE_WEATHER_FORMING
) {
showWeather();
}

return;
}

tapTimeout = setTimeout(function () {
tapCount = 0;
tapTimeout = null;
handleSingleTap();
}, DOUBLE_TAP_DELAY);
},
{ passive: false }
);


/* Поддержка мыши / десктопа.
*/

canvas.addEventListener(
'click',
function () {

if (
'ontouchstart' in window
) {

return;
}


handleSingleTap();
}
);


/* =========================================================
ГЛАВНЫЙ ЦИКЛ
========================================================= */

let lastTime =
performance.now();


function animate(
now
) {

const dt =
now - lastTime;


lastTime =
now;


/*
Чёрный фон.
*/

ctx.fillStyle =
'#000000';


ctx.fillRect(
0,
0,
W,
H
);


/* -------------------------------------------------------
ПОГОДА
------------------------------------------------------- */

/* -------------------------------------------------------
ФОРМИРОВАНИЕ ПОГОДЫ
------------------------------------------------------- */

if (
mode === MODE_WEATHER_FORMING
) {

holdTimer +=
dt;


/*
Если данные уже пришли,
частицы летят к температуре.
*/

if (
holdTimer >
WEATHER_FORM_DURATION
) {

/*
Если температура ещё не получена,
оставляем режим формирования.
*/

if (
weatherPoints.length > 0
) {

mode =
MODE_WEATHER;

holdTimer =
0;
}
}
}


/* -------------------------------------------------------
ФОРМИРОВАНИЕ ЧАСОВ
------------------------------------------------------- */

if (
mode === MODE_FORMING
) {

holdTimer +=
dt;


if (
holdTimer >
FORM_DURATION
) {

mode =
MODE_HOLDING;

holdTimer =
0;
}
}


/* -------------------------------------------------------
ЧАСЫ
------------------------------------------------------- */

else if (
mode === MODE_HOLDING
) {

holdTimer +=
dt;


/*
Автоматически меняем время,
если прошла минута.
*/

const currentText =
getClockText();


if (
currentText !==
lastClockText
) {

clockPoints =
generateTextPoints(
currentText,
0.22
);


lastClockText =
currentText;


assignTargets(
clockPoints
);
}


if (
holdTimer >
HOLD_DURATION
) {

mode =
MODE_RELEASING;

holdTimer =
0;


particles.forEach(
p => {

p.tx = null;
p.ty = null;

}
);
}
}


/* -------------------------------------------------------
РАСПАД
------------------------------------------------------- */

else if (
mode === MODE_RELEASING
) {

holdTimer +=
dt;


if (
holdTimer >
RELEASE_DURATION
) {

mode =
MODE_CHAOS;

weatherArtReleasing = false;
clearWeatherArt();

hint.style.opacity =
'1';
}
}


/* Погодная анимация — отдельный фон. */
if (
mode === MODE_WEATHER_FORMING ||
mode === MODE_WEATHER ||
(mode === MODE_RELEASING && weatherArtReleasing)
) {
drawWeatherArt(now);
}


/* -------------------------------------------------------
ЧАСТИЦЫ — температура поверх погодного слоя
------------------------------------------------------- */

particles.forEach(
p => {

p.update();
p.draw();

}
);


/* -------------------------------------------------------
ИНФОРМАЦИЯ О ПОГОДЕ
------------------------------------------------------- */

if (
mode === MODE_WEATHER
) {

drawWeatherInfo();
}


requestAnimationFrame(
animate
);
}


requestAnimationFrame(
animate
);

</script>

</body>
</html>