-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecord.js
More file actions
393 lines (374 loc) · 19.2 KB
/
Copy pathrecord.js
File metadata and controls
393 lines (374 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
// Headless recorder for README assets. Mounts the REAL dashboard on an
// off-screen blessed screen (no TTY, no desktop capture), stubs audio + network,
// drives a live broadcast in the AUX box, and dumps each terminal cell to SVG —
// foreground AND background — so the thermal map, on-air borders and key bar
// reproduce faithfully. Convert the SVGs to PNG (cairosvg/qlmanage) and stitch a
// GIF with ffmpeg.
//
// node scripts/record.js still -> /tmp/wd/board.svg (one full board)
// node scripts/record.js frames <N> <ms> -> /tmp/wd/f####.svg (broadcast anim)
//
// It never touches your screen; everything is rendered from the modules' own
// output buffer, the same way scripts/mappreview.js and radiopreview.js work.
const fs = require('fs');
const cp = require('child_process');
const { EventEmitter } = require('events');
const https = require('https');
const stream = require('stream');
const blessed = require('blessed');
const bcolors = require('blessed/lib/colors');
const W = 213, H = 66; // the owner's real full-screen size
const OUT = '/tmp/wd';
fs.mkdirSync(OUT, { recursive: true });
// ---- stubs: no audio, no VLC, no real network (panels show idle/standby) -----
const realExists = fs.existsSync;
fs.existsSync = (p) => (String(p).includes('VLC.app') ? true : realExists(p));
cp.spawn = function () {
const proc = new EventEmitter();
proc.stdin = { write() {}, end() {} };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = () => {};
return proc;
};
cp.spawnSync = function () { return { status: 0, stdout: '', stderr: '' }; };
// Canned open-meteo payloads (borrowed from scripts/mappreview.js) so the FL
// thermal map + clocks fill; other feeds (USGS/NWS/CoinGecko/OpenSky) are left
// to their idle/standby states.
function cannedFor(url) {
const lat = parseFloat((url.match(/latitude=([-\d.]+)/) || [])[1]);
const arr = (b, a) => Array.from({ length: 24 }, (_, i) => b + a * Math.sin(i / 3));
if (url.includes('current_weather=true')) {
return {
current_weather: { temperature: 88, windspeed: 9, winddirection: 200 },
hourly: {
temperature_2m: arr(86, 6).map(Math.round),
relative_humidity_2m: arr(68, 12).map(Math.round),
dew_point_2m: arr(72, 3).map(Math.round),
apparent_temperature: arr(95, 5).map(Math.round),
precipitation_probability: arr(28, 22).map(v => Math.max(0, Math.round(v))),
pressure_msl: arr(1013, 2).map(Math.round),
surface_pressure: arr(1012, 2).map(Math.round),
cloud_cover: arr(45, 30).map(v => Math.max(0, Math.round(v))),
visibility: arr(15000, 2000).map(Math.round),
wind_gusts_10m: arr(15, 4).map(Math.round),
soil_temperature_0cm: arr(88, 4).map(Math.round),
uv_index: arr(5, 4).map(v => Math.max(0, +v.toFixed(2))),
direct_radiation: arr(400, 300).map(v => Math.max(0, Math.round(v)))
},
daily: {
sunrise: ['2026-06-14T06:30'], sunset: ['2026-06-14T20:25'],
temperature_2m_max: [94.0], temperature_2m_min: [74.5]
}
};
}
const t = Math.round(94 - (lat - 25.7) * 2.2 + Math.sin(lat * 7) * 2);
return { current: { temperature_2m: t } };
}
// ---- canned, real-SHAPED payloads for every OTHER feed ----------------------
// So the full board renders fully alive (seismic dots + table, market tape,
// radar fleet, FL alert grid, news ticker) instead of half the panels sitting in
// their idle/standby state. Each matches the exact JSON its panel's parser reads.
const NOW = Date.now();
const iso = (msFromNow) => new Date(NOW + msFromNow).toISOString();
// USGS 2.5_day.geojson — a believable global spread so the world map shows dots
// and the data table fills (mag, depth, region, age, tsunami).
function cannedUSGS() {
const Q = [
[6.1, 'Off the coast of Northern Chile', -70.5, -22.3, 35, 1],
[5.4, 'Near the east coast of Honshu, Japan', 141.9, 38.2, 28, 0],
[4.9, 'Rat Islands, Aleutian Islands, AK', -178.4, 51.6, 18, 0],
[4.6, 'Southern Sumatra, Indonesia', 101.2, -3.6, 60, 0],
[4.2, 'Central Turkey', 35.4, 38.7, 7, 0],
[3.8, '121 km W of Mexico City, Mexico', -100.9, 19.1, 15, 0],
[3.5, '8 km NE of Westwood, California', -118.2, 34.1, 9, 0],
[3.1, 'Puerto Rico region', -66.6, 18.2, 12, 0],
[2.9, '14 km S of Volcano, Hawaii', -155.3, 19.3, 4, 0],
[2.7, 'Central Italy', 13.2, 42.6, 10, 0]
];
return { features: Q.map(([mag, place, lon, lat, depth, tsunami], i) => ({
type: 'Feature',
properties: { mag, place, time: NOW - i * 37 * 60000, tsunami },
geometry: { type: 'Point', coordinates: [lon, lat, depth] }
})) };
}
// NWS api.weather.gov/alerts/active?area=FL — a Severe T-storm Watch (red, leads)
// plus a Heat Advisory (yellow). Very Central-Florida-in-June, and it lights the
// ALERT GRID up with real dossiers + countdown timers instead of "ALL CLEAR".
function cannedNWS() {
return { features: [
{ properties: {
event: 'Severe Thunderstorm Watch', severity: 'Severe', urgency: 'Expected',
areaDesc: 'Lake; Orange; Osceola; Polk; Seminole',
sent: iso(-40 * 60000), effective: iso(-40 * 60000), onset: iso(-40 * 60000),
expires: iso(3 * 3600000), ends: iso(3 * 3600000),
headline: 'Severe Thunderstorm Watch until 9 PM EDT' } },
{ properties: {
event: 'Heat Advisory', severity: 'Moderate', urgency: 'Expected',
areaDesc: 'Inland Lake; Inland Volusia; Inland Marion',
sent: iso(-2 * 3600000), effective: iso(-2 * 3600000), onset: iso(-2 * 3600000),
expires: iso(5 * 3600000), ends: iso(5 * 3600000),
headline: 'Heat Advisory until 8 PM EDT' } }
] };
}
// CoinGecko /coins/markets — the 12 ids the panel requests, real-shaped.
function cannedMarket() {
const M = [
['btc', 67250, 0.4, 1.8, 5.2, 41e9, 1.32e12, 65800],
['eth', 3512, 0.2, 2.4, 7.1, 19e9, 4.22e11, 3410],
['sol', 166.4, 1.1, 6.4, 14.8, 5.1e9, 7.8e10, 152.0],
['xrp', 0.621, -0.3, -1.2, 3.4, 1.9e9, 3.45e10, 0.611],
['bnb', 605.2, 0.1, 0.9, -2.1, 1.1e9, 8.9e10, 598.0],
['doge', 0.162, 0.6, 3.7, -4.5, 1.4e9, 2.35e10, 0.155],
['ada', 0.452, -0.1, -2.8, 1.2, 5.6e8, 1.61e10, 0.448],
['avax', 38.2, 0.9, 4.1, 9.7, 4.8e8, 1.49e10, 36.1],
['link', 18.4, 0.3, 2.0, 6.3, 6.2e8, 1.13e10, 17.6],
['dot', 7.18, -0.2, -1.6, -3.2, 2.7e8, 1.02e10, 7.05],
['uni', 11.2, 0.5, 3.3, 8.1, 2.1e8, 6.7e9, 10.6],
['ltc', 92.4, 0.2, 1.1, 4.4, 3.9e8, 6.9e9, 90.1]
];
return M.map(([s, price, h1, h24, d7, vol, mcap, low]) => ({
symbol: s, current_price: price,
price_change_percentage_1h_in_currency: h1,
price_change_percentage_24h: h24,
price_change_percentage_7d_in_currency: d7,
total_volume: vol, market_cap: mcap, low_24h: low
}));
}
// ADS-B point feed (airplanes.live / adsb.lol) — ~14 aircraft over the Florida
// peninsula so the radar shows tracks, a detail card, and a real fleet-stats
// footer (alt/spd spread, climb/descent counts, a mix of origin countries).
function cannedADSB() {
const A = [
['DAL1442', 'N841DN', 'A21N', 'Airbus A321neo', 37000, 38200, 462, 138, 0, '2447', 28.9, -81.2],
['SWA2210', 'N8645B', 'B38M', 'Boeing 737 MAX 8', 35975, 37100, 448, 312, 1280, '1200', 28.2, -81.9],
['JBU615', 'N965JT', 'A320', 'Airbus A320', 31000, 32200, 430, 95, -960, '3613', 27.6, -80.9],
['UAL1701', 'N27721', 'B739', 'Boeing 737-900', 39000, 40100, 471, 280, 0, '6402', 29.4, -82.1],
['ACA1610', 'C-GHPQ', 'B38M', 'Boeing 737 MAX 8', 36000, 37200, 455, 160, 640, '2031', 28.0, -80.6],
['BAW209', 'G-ZBKO', 'B788', 'Boeing 787-8', 41000, 42050, 503, 130, 0, '5210', 29.9, -81.0],
['DLH441', 'D-AIMA', 'A388', 'Airbus A380', 38000, 39200, 512, 145, 0, '4733', 30.1, -82.4],
['CMP418', 'HP-1846CMP', 'B738', 'Boeing 737-800', 33000, 34100, 441, 198, -1120, '1633', 27.1, -80.3],
['CUB552', 'CU-T1704', 'IL96', 'Ilyushin Il-96', 34000, 35000, 420, 250, 0, '2200', 26.8, -81.6],
['BAH301', 'C6-BFK', 'B733', 'Boeing 737-300', 12000, 12600, 312, 110, 1450, '4102', 26.5, -80.1],
['N550QS', 'N550QS', 'C68A', 'Cessna Citation', 43000, 43900, 488, 305, 0, '1456', 28.6, -81.7],
['TAM8154', 'PR-XMD', 'A20N', 'Airbus A320neo', 30000, 31100, 433, 165, -640, '3344', 25.9, -80.5],
['N172SP', 'N172SP', 'C172', 'Cessna 172 Skyhawk', 3500, 3700, 118, 90, 320, '1200', 28.5, -81.4],
['AMX672', 'XA-VOK', 'B738', 'Boeing 737-800', 28000, 29000, 426, 215, 880, '2607', 27.3, -81.2]
];
return { ac: A.map(([flight, r, t, desc, alt_baro, alt_geom, gs, track, baro_rate, squawk, lat, lon], i) => ({
hex: (0xa00000 + i * 0x111).toString(16), flight, r, t, desc,
alt_baro, alt_geom, gs, track, baro_rate, squawk, lat, lon
})) };
}
// rss2json wrapper over Google News RSS — Central Florida headlines for the
// INTEL ticker + the spoken News broadcast (Walter).
function cannedNews() {
return { status: 'ok', items: [
{ title: 'Clermont council approves lakefront park upgrade - Orlando Sentinel' },
{ title: 'Afternoon storms return to Central Florida through the weekend - WESH 2' },
{ title: 'New SunRail station planned for south Lake County - Spectrum News 13' },
{ title: 'Orlando International sets June passenger record - WFTV' },
{ title: 'Lake County opens cooling centers as heat index climbs - FOX 35' },
{ title: 'Winter Garden expands downtown bike trail network - Orlando Weekly' }
] };
}
function cannedBody(url) {
const u = String(url);
if (/open-meteo/.test(u)) return JSON.stringify(cannedFor(u));
if (/earthquake\.usgs\.gov/.test(u)) return JSON.stringify(cannedUSGS());
if (/api\.weather\.gov/.test(u)) return JSON.stringify(cannedNWS());
if (/api\.coingecko\.com/.test(u)) return JSON.stringify(cannedMarket());
if (/rss2json/.test(u)) return JSON.stringify(cannedNews());
if (/airplanes\.live|adsb\.lol/.test(u)) return JSON.stringify(cannedADSB());
return null;
}
https.get = function (url, a, b) {
const cb = typeof a === 'function' ? a : b; // handle (url,cb) & (url,opts,cb)
const req = new EventEmitter();
req.setTimeout = () => req; req.destroy = () => {}; req.end = () => {}; req.abort = () => {};
const body = cannedBody(url);
if (body != null && typeof cb === 'function') {
const res = new EventEmitter();
res.statusCode = 200; res.resume = () => {}; res.setEncoding = () => {};
process.nextTick(() => {
try { cb(res); } catch (_) {}
res.emit('data', body);
res.emit('end');
});
}
return req; // any unmatched feed stays in its idle state
};
// ---- off-screen screen at the real size ------------------------------------
const screen = blessed.screen({
smartCSR: true, fullUnicode: true, dockBorders: true, terminal: 'xterm-256color',
output: new stream.Writable({ write(c, e, cb) { cb(); } })
});
screen.program.cols = W; screen.program.rows = H;
screen.alloc();
const { createGrid } = require('../core/gridLayout');
const contrib = require('blessed-contrib');
const store = require('../core/store');
const { createFloridaMap } = require('../ui/floridaMap');
const { createClocks } = require('../ui/clockModule');
const { createRadar } = require('../ui/radarModule');
const { createWeatherVoice } = require('../ui/weatherVoiceModule');
const createAvatarTranscriptModule = require('../ui/avatarTranscriptModule');
const { createAlerts } = require('../ui/alertsModule');
const { createSeismic } = require('../ui/seismicModule');
const { createMarket } = require('../ui/marketModule');
const { createRadio } = require('../ui/radioModule');
const grid = createGrid(screen, { rows: 12, cols: 12 });
// Use the REAL INTEL FEED log box as the modules' logBox so it fills with their
// genuine status lines (RADIO/SEISMIC/RADAR/MARKET/ALERT...) the way the live app
// does — otherwise this bottom-left panel records as an empty bordered box.
const log = grid.set(8, 0, 3, 1.5, contrib.log, {
fg: 'green', label: ' INTEL FEED ', border: { type: 'line' },
tags: true, style: { fg: 'green', border: { fg: 'cyan' } }
});
log.log('WEATHER TACTICAL COMMAND — SYSTEM ONLINE');
log.log('GRID LOCKED · ALL STATIONS REPORTING');
const radio = createRadio(grid, screen, log);
createMarket(grid, screen, log);
createClocks(grid, screen);
const auxBox = grid.set(2, 4, 6, 4, blessed.box, {
label: ' AUXILIARY MODULE ', border: { type: 'line' },
style: { fg: 'white', border: { fg: 'cyan' } }, content: ''
});
const transcript = createAvatarTranscriptModule(auxBox, screen);
createFloridaMap(grid, screen, log);
createRadar(grid, screen, log);
createSeismic(grid, screen, log);
createAlerts(grid, screen, log);
createWeatherVoice(screen, log, transcript, radio);
grid.set(11, 0, 1, 12, blessed.box, {
wrap: false,
content:
' [P] PLAY [N] NEXT [B] WX [I] NEWS [K] MKT [T] TOTAL [C] CHIME [A] ABOUT [ESC] EXIT\n' +
' weatherDash © 2026 Mikel Jorgensen · mikeljorgensen.com',
style: { fg: 'black', bg: 'cyan' }
});
// ---- feed canned-but-real-shaped state so panels look alive -----------------
// Pin a pleasant partly-cloudy DAY so the LIVE SKY shows the flamingo (not the
// night/SYNCING fallback). Re-asserted on an interval so the voice module's own
// weather publish can't override it during the headless run.
const pinWeather = () => store.setSignal('weather', { code: 2, isDay: true, temp: 88, windMph: 9, humidity: 71 });
pinWeather();
setInterval(pinWeather, 150);
store.setSignal('market', { btc: { change: 1.8 }, mover: { sym: 'SOL', change: 6.4 } });
store.setSignal('seismic', { mag: 5.2, place: 'Off the coast of Chile' });
store.setSignal('radar', { count: 14 });
store.setSignal('news', { headlines: ['Clermont council approves lakefront park upgrade'] });
try { radio.playCurrent && radio.playCurrent(); } catch (_) {}
// ---- drive a live broadcast in the AUX box ----------------------------------
const BRIEF =
"Good evening from Clermont — Alex here on the inland desk. We're sitting at a " +
"muggy 88 degrees with a stiff lake breeze out of the south. Storm fuel is " +
"building west of I-4, so watch for late-afternoon boomers rolling toward the " +
"ridge. Humidity's the story tonight — it feels every bit of the tropics out there.";
function startBroadcast(durMs = 9000) {
transcript.clearTranscriptForNewSession();
transcript.beginSegment('ALEX', 'INLAND WEATHER');
transcript.setSpeechActive(true);
transcript.typeTextSynced(BRIEF, durMs, () => {});
}
// ---- SVG writer: every cell, fg + bg, half-blocks split ---------------------
const LOWER = { '▁': 1/8, '▂': 2/8, '▃': 3/8, '▄': 4/8, '▅': 5/8, '▆': 6/8, '▇': 7/8 };
const rgb = a => `rgb(${a[0]},${a[1]},${a[2]})`;
function toRgb(idx, dflt) {
if (idx === 0x1ff || idx == null) return dflt;
return bcolors.vcolors[idx] || dflt;
}
const BG0 = [0, 0, 0], FG0 = [200, 200, 200];
function svgFor(x0, y0, cols, rows) {
const CW = 8, CH = 16;
const L = screen.lines;
let s = `<svg viewBox="0 0 ${cols*CW} ${rows*CH}" xmlns="http://www.w3.org/2000/svg">`;
s += `<rect width="100%" height="100%" fill="#000"/>`;
s += `<style>text{font-family:'Menlo','DejaVu Sans Mono',monospace;font-size:13px;dominant-baseline:middle;text-anchor:middle}</style>`;
for (let r = 0; r < rows; r++) {
const row = L[y0 + r]; if (!row) continue;
for (let c = 0; c < cols; c++) {
const cell = row[x0 + c]; if (!cell) continue;
const ch = cell[1];
const fg = toRgb((cell[0] >> 9) & 0x1ff, FG0);
const bg = toRgb(cell[0] & 0x1ff, BG0);
const px = c*CW, py = r*CH;
const bgIsBlack = bg[0] === 0 && bg[1] === 0 && bg[2] === 0;
if (!bgIsBlack) s += `<rect x="${px}" y="${py}" width="${CW}" height="${CH}" fill="${rgb(bg)}"/>`;
if (ch === ' ' || !ch) continue;
if (ch === '█') s += `<rect x="${px}" y="${py}" width="${CW}" height="${CH}" fill="${rgb(fg)}"/>`;
else if (ch === '▀') s += `<rect x="${px}" y="${py}" width="${CW}" height="${CH/2}" fill="${rgb(fg)}"/>`;
else if (ch === '▄') s += `<rect x="${px}" y="${py+CH/2}" width="${CW}" height="${CH/2}" fill="${rgb(fg)}"/>`;
else if (ch === '▔') s += `<rect x="${px}" y="${py}" width="${CW}" height="${CH/8}" fill="${rgb(fg)}"/>`;
else if (LOWER[ch] != null) { const h = CH*LOWER[ch]; s += `<rect x="${px}" y="${py+CH-h}" width="${CW}" height="${h}" fill="${rgb(fg)}"/>`; }
else { const e = ch.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); s += `<text x="${px+CW/2}" y="${py+CH/2}" fill="${rgb(fg)}">${e}</text>`; }
}
}
return s + `</svg>`;
}
const mode = process.argv[2] || 'still';
if (mode === 'panels') {
setTimeout(() => {
startBroadcast();
setTimeout(() => {
screen.render();
const dump = (name, x, y, c, r) => fs.writeFileSync(`${OUT}/${name}.svg`, svgFor(x, y, c, r));
dump('board', 0, 0, W, H);
dump('map', 0, 11, 72, 32); // FL THEATER (2,0,6,4)
dump('radio', 0, 0, 72, 11); // VIBE MACHINE (0,0,2,4)
dump('aux', 71, 11, 71, 33); // full AUX broadcast
dump('seismic', 27, 43, 88, 17);// SEISMIC world map (8,1.5,3,5)
console.log(`wrote panel crops to ${OUT}`);
process.exit(0);
}, 4200); // let open-meteo land + a few scene frames
}, 1000);
} else if (mode === 'board') {
// FULL-BOARD animation -> the README hero GIF. Every panel populated (all feeds
// stubbed above), a weather broadcast typing in the AUX box, the flamingo +
// spectrum + alert blink all in motion. Captures the whole 213x66 screen.
const N = parseInt(process.argv[3] || '32', 10);
const STEP = parseInt(process.argv[4] || '200', 10);
setTimeout(() => { // let every feed land + the FL map warm
startBroadcast(N * STEP - 600); // type for ~the capture window so it reads continuous
let i = 0;
const tick = () => {
screen.render();
fs.writeFileSync(`${OUT}/b${String(i).padStart(4, '0')}.svg`, svgFor(0, 0, W, H));
i++;
if (i >= N) { console.log(`wrote ${N} board frames to ${OUT}`); process.exit(0); }
setTimeout(tick, STEP);
};
setTimeout(tick, 500); // ~0.5s in so frame 1 already shows typed text
}, 3600);
} else if (mode === 'still') {
setTimeout(() => {
startBroadcast(6500);
setTimeout(() => {
screen.render();
fs.writeFileSync(`${OUT}/board.svg`, svgFor(0, 0, W, H));
// AUX box crop (the broadcast): grid cell (2,4,6,4) -> x71..141 y11..43
fs.writeFileSync(`${OUT}/aux.svg`, svgFor(71, 11, 71, 33));
// LIVE SKY only (left of AUX) — daytime flamingo (never-return https keeps
// the partly-cloudy signal set above, so this is the clean day scene)
fs.writeFileSync(`${OUT}/sky.svg`, svgFor(72, 12, 22, 31));
console.log(`wrote ${OUT}/board.svg + aux.svg + sky.svg`);
process.exit(0);
}, 5200); // all feeds landed + a good chunk of the brief typed
}, 3600);
} else { // frames N stepMs -> capture the broadcast typing
const N = parseInt(process.argv[3] || '28', 10);
const STEP = parseInt(process.argv[4] || '320', 10);
setTimeout(() => {
startBroadcast();
let i = 0;
const tick = () => {
screen.render();
// crop to the AUX box (the live broadcast) — the README hero
fs.writeFileSync(`${OUT}/f${String(i).padStart(4,'0')}.svg`, svgFor(71, 11, 71, 33));
i++;
if (i >= N) { console.log(`wrote ${N} frames to ${OUT}`); process.exit(0); }
setTimeout(tick, STEP);
};
setTimeout(tick, 400);
}, 1200);
}