``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}` +
'¤t=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>