infinicode 2.8.36 → 2.8.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +3440 -231
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +56 -0
- package/package.json +105 -105
- package/packages/robopark/scheduler/main.py +838 -29
- package/packages/robopark/scheduler/preview_agent.py +620 -57
- package/packages/robopark/scheduler/robot_supervisor.py +320 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/packages/robopark/scheduler/scripts/robopark-supervisor.service +20 -0
- package/packages/robopark/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/packages/robopark/scheduler/supervisor.example.json +26 -0
- package/packages/robopark/scheduler/vision_motion_trigger.py +101 -0
|
@@ -319,6 +319,170 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
319
319
|
.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
|
|
320
320
|
.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
|
|
321
321
|
.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
|
|
322
|
+
.pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}
|
|
323
|
+
.pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
|
|
324
|
+
.pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
|
|
325
|
+
.pk-prodtoggle:has(input:checked){border-color:var(--ok);background:rgba(60,180,110,0.12);}
|
|
326
|
+
.pk-svc-list{display:flex;flex-direction:column;gap:0.35rem;}
|
|
327
|
+
.pk-svc-row{display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.55rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.64rem;}
|
|
328
|
+
.pk-svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--muted);}
|
|
329
|
+
.pk-svc-row.up .pk-svc-dot{background:var(--ok);box-shadow:0 0 6px var(--ok);}
|
|
330
|
+
.pk-svc-row.down .pk-svc-dot{background:var(--bad);}
|
|
331
|
+
.pk-svc-row.disabled .pk-svc-dot{background:var(--muted);}
|
|
332
|
+
.pk-svc-name{font-weight:600;color:var(--ink);min-width:6.5em;}
|
|
333
|
+
.pk-svc-meta{color:var(--muted);flex:1;}
|
|
334
|
+
.pk-svc-restart-btn{font-size:0.6rem;padding:0.2rem 0.5rem;flex-shrink:0;}
|
|
335
|
+
|
|
336
|
+
/* ── RoboPark Sessions & Telemetry — sub-tab control room ── */
|
|
337
|
+
.rp-subtabs{display:flex;gap:0.15rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
|
|
338
|
+
background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
|
|
339
|
+
position:sticky;top:97px;z-index:18;flex-wrap:wrap;}
|
|
340
|
+
.rp-subtab{font-family:var(--mono);font-size:0.72rem;padding:0.55rem 0.85rem;cursor:pointer;color:var(--muted);background:none;border:none;
|
|
341
|
+
border-bottom:2px solid transparent;display:inline-flex;align-items:center;gap:0.4rem;transition:color .12s,border-color .12s;}
|
|
342
|
+
.rp-subtab:hover{color:var(--ink);}
|
|
343
|
+
.rp-subtab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}
|
|
344
|
+
.rp-subtab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
|
|
345
|
+
.rp-subtab .badge{font-family:var(--mono);font-size:0.6rem;padding:0.04em 0.45em;border-radius:100px;background:var(--panel-2);
|
|
346
|
+
border:1px solid var(--glass-border);color:var(--muted);font-weight:600;}
|
|
347
|
+
.rp-subtab.active .badge{color:var(--gold-bright);border-color:var(--border-strong);}
|
|
348
|
+
.rp-sub{display:none;padding:0.8rem 1.1rem 1.5rem;}
|
|
349
|
+
.rp-sub.active{display:block;}
|
|
350
|
+
|
|
351
|
+
.rp-kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.7rem;margin-bottom:0.9rem;}
|
|
352
|
+
.rp-kpi{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);
|
|
353
|
+
-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
354
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);position:relative;overflow:hidden;}
|
|
355
|
+
.rp-kpi::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,rgba(255,214,112,0.04),transparent 60%);}
|
|
356
|
+
.rp-kpi .l{font-family:var(--mono);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);margin-bottom:0.25rem;}
|
|
357
|
+
.rp-kpi .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.7rem;color:var(--ink);font-weight:600;line-height:1.1;}
|
|
358
|
+
.rp-kpi .v.ok{color:var(--ok);} .rp-kpi .v.warn{color:var(--warn);} .rp-kpi .v.bad{color:var(--bad);} .rp-kpi .v.info{color:var(--info);}
|
|
359
|
+
.rp-kpi .s{font-family:var(--mono);font-size:0.62rem;color:var(--muted);margin-top:0.2rem;}
|
|
360
|
+
|
|
361
|
+
.rp-mini-chart-wrap{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);
|
|
362
|
+
-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
363
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);margin-bottom:0.9rem;}
|
|
364
|
+
.rp-mini-chart-wrap h4{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);
|
|
365
|
+
margin-bottom:0.4rem;display:flex;justify-content:space-between;align-items:baseline;}
|
|
366
|
+
.rp-mini-chart-wrap h4 .now{color:var(--muted);font-size:0.6rem;}
|
|
367
|
+
.rp-mini-chart{width:100%;height:120px;display:block;}
|
|
368
|
+
|
|
369
|
+
.rp-section-h{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);
|
|
370
|
+
margin:0.2rem 0 0.55rem;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;}
|
|
371
|
+
.rp-section-h .meta{color:var(--muted);font-size:0.62rem;text-transform:none;letter-spacing:0.02em;font-weight:400;}
|
|
372
|
+
|
|
373
|
+
.rp-top3{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.6rem;margin-bottom:0.9rem;}
|
|
374
|
+
.rp-top3 .rp-card{padding:0.55rem 0.7rem;}
|
|
375
|
+
|
|
376
|
+
.rp-stream{border-radius:13px;background:var(--panel);
|
|
377
|
+
-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
378
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);padding:0.55rem 0.7rem;max-height:300px;overflow-y:auto;}
|
|
379
|
+
.rp-stream .line{padding:0.22rem 0.4rem;}
|
|
380
|
+
.rp-stream .line .ic{font-size:0.8rem;flex:none;}
|
|
381
|
+
|
|
382
|
+
.rp-filterbar{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;padding:0.5rem 0.7rem;background:var(--panel-2);
|
|
383
|
+
border:1px solid var(--glass-border);border-radius:11px;margin-bottom:0.6rem;}
|
|
384
|
+
.rp-filterbar .rp-field{flex:1;min-width:120px;max-width:240px;}
|
|
385
|
+
.rp-filterbar .rp-field label{font-size:0.6rem;}
|
|
386
|
+
.rp-filterbar .rp-field input,.rp-filterbar .rp-field select{font-size:0.7rem;padding:0.35rem 0.5rem;}
|
|
387
|
+
.rp-filterbar .rp-btn{padding:0.32rem 0.55rem;}
|
|
388
|
+
|
|
389
|
+
.rp-sess-table,.rp-history-table,.rp-audit-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.7rem;background:var(--panel);
|
|
390
|
+
-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
391
|
+
border:1px solid var(--glass-border);border-radius:11px;overflow:hidden;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
392
|
+
.rp-sess-table th,.rp-history-table th,.rp-audit-table th{padding:0.55rem 0.7rem;text-align:left;font-weight:600;
|
|
393
|
+
font-size:0.6rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);background:var(--panel-2);
|
|
394
|
+
border-bottom:1px solid var(--glass-border);position:sticky;top:0;z-index:1;}
|
|
395
|
+
.rp-sess-table td,.rp-history-table td,.rp-audit-table td{padding:0.5rem 0.7rem;border-bottom:1px solid var(--glass-border);
|
|
396
|
+
color:var(--ink);vertical-align:middle;}
|
|
397
|
+
.rp-sess-table tr:last-child td,.rp-history-table tr:last-child td,.rp-audit-table tr:last-child td{border-bottom:none;}
|
|
398
|
+
.rp-sess-table tr:hover td,.rp-history-table tr:hover td,.rp-audit-table tr:hover td{background:color-mix(in srgb,var(--gold-bright) 5%,transparent);}
|
|
399
|
+
.rp-sess-table .sid,.rp-history-table .sid{font-size:0.62rem;color:var(--muted);max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}
|
|
400
|
+
.rp-sess-table .t-rel{color:var(--ink);display:block;font-size:0.7rem;}
|
|
401
|
+
.rp-sess-table .t-abs{color:var(--muted);font-size:0.58rem;}
|
|
402
|
+
.rp-sess-table .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 6px var(--ok);margin-right:0.4rem;animation:pulse 2.4s infinite;vertical-align:middle;}
|
|
403
|
+
.rp-tbl-wrap{max-height:520px;overflow:auto;border-radius:11px;}
|
|
404
|
+
.rp-tbl-pager{display:flex;justify-content:space-between;align-items:center;padding:0.55rem 0.2rem;gap:0.5rem;font-family:var(--mono);font-size:0.66rem;color:var(--muted);}
|
|
405
|
+
.rp-tbl-pager .rp-btn{padding:0.3rem 0.55rem;}
|
|
406
|
+
.rp-tbl-empty{padding:1.5rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:0.75rem;}
|
|
407
|
+
.rp-end-badge{display:inline-block;padding:0.05em 0.45em;border-radius:5px;font-size:0.6rem;font-family:var(--mono);
|
|
408
|
+
border:1px solid var(--glass-border);color:var(--muted);background:var(--panel-2);}
|
|
409
|
+
.rp-end-badge.operator{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:var(--bad-bg);}
|
|
410
|
+
.rp-end-badge.silence{color:var(--muted);}
|
|
411
|
+
.rp-end-badge.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
|
|
412
|
+
.rp-end-badge.timeout{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:var(--warn-bg);}
|
|
413
|
+
.rp-end-badge.completed{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:var(--ok-bg);}
|
|
414
|
+
.rp-end-badge.active,.rp-end-badge.running{color:var(--ok);border-color:color(--ok);background:var(--ok-bg);}
|
|
415
|
+
|
|
416
|
+
.rp-cat-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;
|
|
417
|
+
border:1px solid var(--glass-border);background:var(--panel-2);color:var(--muted);cursor:pointer;
|
|
418
|
+
display:inline-flex;align-items:center;gap:0.3rem;transition:all .12s;}
|
|
419
|
+
.rp-cat-chip:hover{color:var(--ink);border-color:var(--border-strong);}
|
|
420
|
+
.rp-cat-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}
|
|
421
|
+
.rp-cat-chip .ct{font-size:0.55rem;opacity:0.8;}
|
|
422
|
+
|
|
423
|
+
.rp-modal-back{position:fixed;inset:0;z-index:60;background:rgba(4,3,12,0.62);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
|
|
424
|
+
display:none;align-items:flex-start;justify-content:center;padding:6vh 1rem 1rem;overflow-y:auto;}
|
|
425
|
+
.rp-modal-back.open{display:flex;}
|
|
426
|
+
.rp-modal{position:relative;width:min(720px,100%);max-height:80vh;background:linear-gradient(180deg,var(--panel-2),var(--panel));
|
|
427
|
+
-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
|
|
428
|
+
border:1px solid var(--border-strong);border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,0.55);display:flex;flex-direction:column;overflow:hidden;}
|
|
429
|
+
.rp-modal-head{display:flex;align-items:center;gap:0.6rem;padding:0.8rem 1rem;border-bottom:1px solid var(--glass-border);background:var(--panel-2);}
|
|
430
|
+
.rp-modal-head h3{font-family:var(--mono);font-size:0.86rem;font-weight:600;color:var(--gold-bright);flex:1;word-break:break-word;}
|
|
431
|
+
.rp-modal-head .x{background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;
|
|
432
|
+
width:30px;height:30px;cursor:pointer;font-size:0.95rem;flex:none;}
|
|
433
|
+
.rp-modal-head .x:hover{color:var(--ink);border-color:var(--border-strong);}
|
|
434
|
+
.rp-modal-body{padding:1rem;overflow-y:auto;flex:1;font-family:var(--mono);font-size:0.72rem;line-height:1.5;color:var(--ink);}
|
|
435
|
+
.rp-modal-body .kv{display:grid;grid-template-columns:160px 1fr;gap:0.4rem 0.8rem;margin-bottom:0.4rem;}
|
|
436
|
+
.rp-modal-body .kv .k{color:var(--muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.05em;}
|
|
437
|
+
.rp-modal-body .kv .v{color:var(--ink);word-break:break-word;white-space:pre-wrap;}
|
|
438
|
+
.rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;
|
|
439
|
+
font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}
|
|
440
|
+
|
|
441
|
+
/* ── TTS voice picker (used inside the generic modal) ── */
|
|
442
|
+
.rp-voice-picker{display:flex;flex-direction:column;gap:0.6rem;min-height:320px;}
|
|
443
|
+
.rp-voice-picker-toolbar{display:flex;gap:0.4rem;align-items:stretch;}
|
|
444
|
+
.rp-voice-picker-toolbar input[type=search]{flex:1;min-width:0;font-family:var(--mono);font-size:0.78rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:8px;padding:0.5rem 0.7rem;}
|
|
445
|
+
.rp-voice-picker-toolbar input[type=search]:focus{outline:none;border-color:var(--gold-bright);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 18%,transparent);}
|
|
446
|
+
.rp-voice-picker-status{font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.5rem;min-height:1.1em;}
|
|
447
|
+
.rp-voice-picker-status.busy{color:var(--gold-bright);}
|
|
448
|
+
.rp-voice-picker-status.bad{color:var(--bad);}
|
|
449
|
+
.rp-voice-picker-status.warn{color:var(--warn,#e0a23a);}
|
|
450
|
+
.rp-voice-picker-status .rp-mono{font-family:var(--mono);}
|
|
451
|
+
.rp-voice-picker-status .rp-spinner{display:inline-block;width:0.8em;height:0.8em;border:2px solid color-mix(in srgb,var(--gold-bright) 30%,transparent);border-top-color:var(--gold-bright);border-radius:50%;animation:rp-vp-spin 0.8s linear infinite;margin-right:0.25rem;}
|
|
452
|
+
@keyframes rp-vp-spin{to{transform:rotate(360deg);}}
|
|
453
|
+
.rp-voice-picker-results{display:flex;flex-direction:column;gap:0.7rem;max-height:55vh;overflow-y:auto;padding-right:0.25rem;}
|
|
454
|
+
.rp-voice-picker-empty{color:var(--muted);font-style:italic;padding:1rem;text-align:center;border:1px dashed var(--glass-border);border-radius:8px;}
|
|
455
|
+
.rp-vp-group{display:flex;flex-direction:column;gap:0.3rem;}
|
|
456
|
+
.rp-vp-group-head{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;padding:0.1rem 0.2rem;}
|
|
457
|
+
.rp-vp-item{display:block;text-align:left;width:100%;background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;cursor:pointer;color:var(--ink);font-family:var(--mono);transition:border-color .12s, background .12s, transform .04s;}
|
|
458
|
+
.rp-vp-item:hover{border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 6%,var(--panel-solid));}
|
|
459
|
+
.rp-vp-item:active{transform:translateY(1px);}
|
|
460
|
+
.rp-vp-item:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
461
|
+
.rp-vp-row1{display:flex;align-items:baseline;gap:0.5rem;justify-content:space-between;}
|
|
462
|
+
.rp-vp-name{font-size:0.78rem;font-weight:600;color:var(--ink);}
|
|
463
|
+
.rp-vp-meta{font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;flex:none;}
|
|
464
|
+
.rp-vp-id{font-size:0.62rem;color:var(--muted);margin-top:0.1rem;word-break:break-all;}
|
|
465
|
+
.rp-vp-desc{font-size:0.64rem;color:var(--muted);margin-top:0.2rem;line-height:1.4;}
|
|
466
|
+
.rp-voice-picker-foot{font-size:0.6rem;color:var(--muted);text-align:center;padding-top:0.2rem;border-top:1px solid var(--glass-border);}
|
|
467
|
+
.rp-voice-picker-foot .rp-muted{color:var(--muted);}
|
|
468
|
+
|
|
469
|
+
.rp-fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:0.7rem;padding:0;}
|
|
470
|
+
.rp-fleet-card{position:relative;}
|
|
471
|
+
.rp-fleet-card .spark{margin-top:0.5rem;height:36px;}
|
|
472
|
+
.rp-fleet-card .rp-actions{margin-top:0.6rem;}
|
|
473
|
+
.rp-fleet-card .pulse{position:absolute;top:0.75rem;right:0.85rem;display:flex;align-items:center;gap:0.35rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
|
|
474
|
+
.rp-fleet-card .pulse .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);}
|
|
475
|
+
.rp-fleet-card .pulse.live .dot{background:var(--ok);box-shadow:0 0 8px var(--ok);animation:pulse 2.4s infinite;}
|
|
476
|
+
.rp-fleet-card .pulse.idle .dot{background:var(--muted);}
|
|
477
|
+
.rp-fleet-card .pulse.offline .dot{background:var(--bad);}
|
|
478
|
+
.rp-spark{width:100%;height:36px;display:block;opacity:0.92;}
|
|
479
|
+
|
|
480
|
+
.rp-setup-grid{display:grid;grid-template-columns:1fr 1fr;gap:0.9rem;}
|
|
481
|
+
@media (max-width:980px){.rp-setup-grid{grid-template-columns:1fr;}}
|
|
482
|
+
|
|
483
|
+
.rp-form .rp-form{margin:0.5rem 0 0;padding:0.6rem 0.75rem;}
|
|
484
|
+
|
|
485
|
+
.rp-input-grow{flex:1;min-width:140px;}
|
|
322
486
|
</style>
|
|
323
487
|
</head>
|
|
324
488
|
<body>
|
|
@@ -395,120 +559,264 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
395
559
|
</div>
|
|
396
560
|
</div>
|
|
397
561
|
|
|
398
|
-
<!--
|
|
399
|
-
<div class="rp-
|
|
400
|
-
<
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
562
|
+
<!-- Sub-tab nav bar (sticky, just under main tabs) -->
|
|
563
|
+
<div class="rp-subtabs" id="rp-subtabs" role="tablist">
|
|
564
|
+
<button class="rp-subtab active" data-sub="overview" id="rpsbtn-overview" role="tab" aria-selected="true">Overview</button>
|
|
565
|
+
<button class="rp-subtab" data-sub="live" id="rpsbtn-live" role="tab" aria-selected="false">Live Sessions <span class="badge" id="rps-live-badge">0</span></button>
|
|
566
|
+
<button class="rp-subtab" data-sub="history" id="rpsbtn-history" role="tab" aria-selected="false">History</button>
|
|
567
|
+
<button class="rp-subtab" data-sub="audit" id="rpsbtn-audit" role="tab" aria-selected="false">Audit Log</button>
|
|
568
|
+
<button class="rp-subtab" data-sub="fleet" id="rpsbtn-fleet" role="tab" aria-selected="false">Fleet</button>
|
|
569
|
+
<button class="rp-subtab" data-sub="setup" id="rpsbtn-setup" role="tab" aria-selected="false">Setup</button>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<!-- ═══ Overview ═══ -->
|
|
573
|
+
<div class="rp-sub active" id="rp-sub-overview">
|
|
574
|
+
<div class="rp-kpi-grid" id="rp-overview-kpis"></div>
|
|
575
|
+
<div class="rp-mini-chart-wrap">
|
|
576
|
+
<h4>Fleet health · sessions (last 60 min) <span class="now" id="rp-overview-now">—</span></h4>
|
|
577
|
+
<canvas class="rp-mini-chart" id="rp-overview-chart" aria-label="Sessions over time"></canvas>
|
|
405
578
|
</div>
|
|
406
|
-
<div class="rp-
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
<div class="rp-field"><label>ID</label><input id="srv-id" placeholder="auto-filled from name"></div>
|
|
411
|
-
<div class="rp-field"><label>URL (ws:// or wss://)</label><input id="srv-url" placeholder="ws://host:7880 — auto-detect fills this"></div>
|
|
412
|
-
<div class="rp-field"><label>API key</label><input id="srv-key" placeholder="devkey" value="devkey"></div>
|
|
413
|
-
<div class="rp-field"><label>API secret</label><input type="password" id="srv-secret" placeholder="secretsecretsecretsecretsecretsecret"></div>
|
|
414
|
-
<div class="rp-field"><label>Max sessions</label><input id="srv-max" type="number" value="8"></div>
|
|
415
|
-
</div>
|
|
416
|
-
</details>
|
|
579
|
+
<div class="rp-section-h">Top robots by activity <span class="meta" id="rp-overview-top-meta"></span></div>
|
|
580
|
+
<div class="rp-top3" id="rp-overview-top"></div>
|
|
581
|
+
<div class="rp-section-h">Recent activity <span class="meta" id="rp-overview-stream-meta">last 20</span></div>
|
|
582
|
+
<div class="rp-stream" id="rp-overview-stream"></div>
|
|
417
583
|
</div>
|
|
418
584
|
|
|
419
|
-
<!--
|
|
420
|
-
<div class="rp-
|
|
421
|
-
<
|
|
422
|
-
|
|
423
|
-
<div class="rp-field"><label>Robot
|
|
424
|
-
<
|
|
425
|
-
|
|
585
|
+
<!-- ═══ Live Sessions ═══ -->
|
|
586
|
+
<div class="rp-sub" id="rp-sub-live">
|
|
587
|
+
<div class="rp-filterbar">
|
|
588
|
+
<div class="rp-field"><label>Search</label><input id="rp-live-search" placeholder="session id, robot…"></div>
|
|
589
|
+
<div class="rp-field"><label>Robot</label><select id="rp-live-robot"><option value="">all robots</option></select></div>
|
|
590
|
+
<label class="rp-btn" style="display:inline-flex;align-items:center;gap:0.35rem;cursor:pointer;">
|
|
591
|
+
<input type="checkbox" id="rp-live-autoref" checked style="width:auto;margin:0;accent-color:var(--gold-bright);"> auto-refresh
|
|
592
|
+
</label>
|
|
593
|
+
<button class="rp-btn" id="rp-live-refresh">↻ Refresh now</button>
|
|
426
594
|
</div>
|
|
427
|
-
<div class="rp-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
595
|
+
<div class="rp-tbl-wrap" id="rp-live-wrap">
|
|
596
|
+
<table class="rp-sess-table" id="rp-live-table">
|
|
597
|
+
<thead><tr>
|
|
598
|
+
<th>Robot</th><th>Session</th><th>Started</th><th>Duration</th><th>End reason</th><th>Actions</th>
|
|
599
|
+
</tr></thead>
|
|
600
|
+
<tbody id="rp-live-tbody"></tbody>
|
|
601
|
+
</table>
|
|
431
602
|
</div>
|
|
432
|
-
<
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
603
|
+
<div class="rp-tbl-empty" id="rp-live-empty" style="display:none;">No active sessions right now — sessions appear here the moment a robot joins a LiveKit room.</div>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<!-- ═══ History ═══ -->
|
|
607
|
+
<div class="rp-sub" id="rp-sub-history">
|
|
608
|
+
<div class="rp-filterbar">
|
|
609
|
+
<div class="rp-field"><label>Search</label><input id="rp-hist-search" placeholder="session id, robot…"></div>
|
|
610
|
+
<div class="rp-field"><label>Robot</label><select id="rp-hist-robot"><option value="">all robots</option></select></div>
|
|
611
|
+
<div class="rp-field"><label>End reason</label><select id="rp-hist-reason">
|
|
612
|
+
<option value="">any</option>
|
|
613
|
+
<option value="operator">operator</option>
|
|
614
|
+
<option value="silence">silence</option>
|
|
615
|
+
<option value="error">error</option>
|
|
616
|
+
<option value="timeout">timeout</option>
|
|
617
|
+
<option value="completed">completed</option>
|
|
618
|
+
<option value="unknown">unknown</option>
|
|
619
|
+
</select></div>
|
|
620
|
+
<div class="rp-field"><label>Range</label><select id="rp-hist-range">
|
|
621
|
+
<option value="24h">last 24h</option>
|
|
622
|
+
<option value="7d" selected>last 7 days</option>
|
|
623
|
+
<option value="30d">last 30 days</option>
|
|
624
|
+
<option value="all">all</option>
|
|
625
|
+
</select></div>
|
|
626
|
+
<button class="rp-btn" id="rp-hist-refresh">↻ Refresh</button>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="rp-tbl-wrap" id="rp-hist-wrap">
|
|
629
|
+
<table class="rp-history-table" id="rp-hist-table">
|
|
630
|
+
<thead><tr>
|
|
631
|
+
<th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Triggers</th><th>Latency</th><th>Actions</th>
|
|
632
|
+
</tr></thead>
|
|
633
|
+
<tbody id="rp-hist-tbody"></tbody>
|
|
634
|
+
</table>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="rp-tbl-empty" id="rp-hist-empty" style="display:none;">No sessions match the current filters.</div>
|
|
637
|
+
<div class="rp-tbl-pager" id="rp-hist-pager">
|
|
638
|
+
<span id="rp-hist-info">—</span>
|
|
639
|
+
<div>
|
|
640
|
+
<button class="rp-btn" id="rp-hist-prev">‹ Prev</button>
|
|
641
|
+
<button class="rp-btn" id="rp-hist-next">Next ›</button>
|
|
440
642
|
</div>
|
|
441
|
-
</
|
|
643
|
+
</div>
|
|
442
644
|
</div>
|
|
443
645
|
|
|
444
|
-
|
|
646
|
+
<!-- ═══ Audit Log ═══ -->
|
|
647
|
+
<div class="rp-sub" id="rp-sub-audit">
|
|
648
|
+
<div class="rp-filterbar" style="gap:0.4rem;">
|
|
649
|
+
<span class="rp-cat-chip active" data-cat="" id="rp-audit-cat-all">All</span>
|
|
650
|
+
<span class="rp-cat-chip" data-cat="robot" id="rp-audit-cat-robot">🤖 Robot</span>
|
|
651
|
+
<span class="rp-cat-chip" data-cat="device" id="rp-audit-cat-device">📡 Device</span>
|
|
652
|
+
<span class="rp-cat-chip" data-cat="session" id="rp-audit-cat-session">🎙 Session</span>
|
|
653
|
+
<span class="rp-cat-chip" data-cat="server" id="rp-audit-cat-server">🖥 Server</span>
|
|
654
|
+
<span class="rp-cat-chip" data-cat="voice" id="rp-audit-cat-voice">🗣 Voice</span>
|
|
655
|
+
<span class="rp-cat-chip" data-cat="character" id="rp-audit-cat-character">🎭 Character</span>
|
|
656
|
+
<div style="flex:1;"></div>
|
|
657
|
+
<button class="rp-btn" id="rp-audit-refresh">↻ Refresh</button>
|
|
658
|
+
</div>
|
|
659
|
+
<div class="rp-tbl-wrap" id="rp-audit-wrap">
|
|
660
|
+
<table class="rp-audit-table" id="rp-audit-table">
|
|
661
|
+
<thead><tr>
|
|
662
|
+
<th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>Source</th>
|
|
663
|
+
</tr></thead>
|
|
664
|
+
<tbody id="rp-audit-tbody"></tbody>
|
|
665
|
+
</table>
|
|
666
|
+
</div>
|
|
667
|
+
<div class="rp-tbl-empty" id="rp-audit-empty" style="display:none;">No audit events match the current filter.</div>
|
|
668
|
+
</div>
|
|
445
669
|
|
|
446
|
-
<!--
|
|
447
|
-
<div class="rp-
|
|
448
|
-
<
|
|
449
|
-
<div>
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
670
|
+
<!-- ═══ Fleet ═══ -->
|
|
671
|
+
<div class="rp-sub" id="rp-sub-fleet">
|
|
672
|
+
<div class="rp-section-h">Robots <span class="meta" id="rp-fleet-meta">live + idle + offline</span></div>
|
|
673
|
+
<div class="rp-fleet-grid" id="rp-grid"></div>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
<!-- ═══ Setup ═══ -->
|
|
677
|
+
<div class="rp-sub" id="rp-sub-setup">
|
|
678
|
+
<!-- Add LiveKit server -->
|
|
679
|
+
<div class="rp-form" id="rp-addserver">
|
|
680
|
+
<h3>Add LiveKit server</h3>
|
|
681
|
+
<div class="rp-quickrow">
|
|
682
|
+
<div class="rp-field"><label>Name</label><input id="srv-name" placeholder="LiveKit Server 1"></div>
|
|
683
|
+
<button class="rp-btn primary" id="srv-detect" style="height:fit-content" title="Probes this hub's own host + common local ports for a running LiveKit instance">🔍 Auto-detect & fill</button>
|
|
684
|
+
<button class="rp-btn" id="srv-add" style="height:fit-content">Add server</button>
|
|
455
685
|
</div>
|
|
456
|
-
<div class="rp-
|
|
457
|
-
|
|
458
|
-
<
|
|
459
|
-
<div class="rp-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
<div class="rp-field"><label>Context size (num_ctx)</label><input id="rpve-num-ctx" type="number" value="8192"></div>
|
|
466
|
-
<div class="rp-field"><label>TTS provider</label><select id="rpve-tts-provider"><option value="elevenlabs">elevenlabs</option><option value="kokoro">kokoro</option><option value="piper">piper</option></select></div>
|
|
467
|
-
<div class="rp-field"><label>TTS voice</label><input id="rpve-tts-voice"></div>
|
|
468
|
-
<div class="rp-field"><label>TTS language</label><input id="rpve-tts-language" placeholder="en"></div>
|
|
469
|
-
<div class="rp-field"><label>Max turns</label><input id="rpve-max-turns" type="number" value="20"></div>
|
|
470
|
-
<div class="rp-field"><label>Min endpointing delay (s)</label><input id="rpve-endpointing" type="number" step="0.1" value="0.7"></div>
|
|
471
|
-
<div class="rp-field"><label><input type="checkbox" id="rpve-interruptions" checked style="width:auto;margin-right:0.35rem;">Allow interruptions</label></div>
|
|
472
|
-
<div class="rp-field"><label><input type="checkbox" id="rpve-wake-enabled" style="width:auto;margin-right:0.35rem;">Wake word enabled</label></div>
|
|
473
|
-
<div class="rp-field"><label>Wake word model</label><input id="rpve-wake-model" placeholder="models/hey_jarvis.onnx"></div>
|
|
474
|
-
<div class="rp-field"><label>Wake threshold</label><input id="rpve-wake-threshold" type="number" step="0.05" value="0.5"></div>
|
|
475
|
-
<div class="rp-field"><label>Wake timeout (s)</label><input id="rpve-wake-timeout" type="number" step="0.5" value="3.0"></div>
|
|
476
|
-
<div style="grid-column:1/-1;display:flex;gap:0.4rem;">
|
|
477
|
-
<button class="rp-btn" id="rpve-save">Save</button>
|
|
478
|
-
<button class="rp-btn" id="rpve-cancel">Cancel</button>
|
|
686
|
+
<div class="rp-autonote" id="srv-autonote"></div>
|
|
687
|
+
<details class="rp-advanced">
|
|
688
|
+
<summary>Advanced — ID, URL, credentials, capacity</summary>
|
|
689
|
+
<div class="rp-advanced-grid">
|
|
690
|
+
<div class="rp-field"><label>ID</label><input id="srv-id" placeholder="auto-filled from name"></div>
|
|
691
|
+
<div class="rp-field"><label>URL (ws:// or wss://)</label><input id="srv-url" placeholder="ws://host:7880 — auto-detect fills this"></div>
|
|
692
|
+
<div class="rp-field"><label>API key</label><input id="srv-key" placeholder="devkey" value="devkey"></div>
|
|
693
|
+
<div class="rp-field"><label>API secret</label><input type="password" id="srv-secret" placeholder="secretsecretsecretsecretsecretsecret"></div>
|
|
694
|
+
<div class="rp-field"><label>Max sessions</label><input id="srv-max" type="number" value="8"></div>
|
|
479
695
|
</div>
|
|
696
|
+
</details>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
<!-- Add robot / device -->
|
|
700
|
+
<div class="rp-form" id="rp-addrobot">
|
|
701
|
+
<h3>Add robot (mint enrollment token)</h3>
|
|
702
|
+
<div class="rp-quickrow">
|
|
703
|
+
<div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
|
|
704
|
+
<div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
|
|
705
|
+
<button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
|
|
706
|
+
</div>
|
|
707
|
+
<div class="rp-autonote" id="rb-autonote">the robot self-reports its IP and hardware on first heartbeat — you only need a name and (optionally) a character preset to get started.</div>
|
|
708
|
+
<div class="rp-quickrow" id="rb-clicmd-row" style="display:none;">
|
|
709
|
+
<div class="rp-field" style="flex:1;"><label>run this ON THE ROBOT (no need to click Add — this mints its own token)</label><input id="rb-clicmd" readonly style="cursor:text;"></div>
|
|
710
|
+
<button class="rp-btn" id="rb-clicmd-copy" style="height:fit-content">📋 Copy</button>
|
|
480
711
|
</div>
|
|
712
|
+
<details class="rp-advanced">
|
|
713
|
+
<summary>Advanced — pre-set network/voice config before the robot connects</summary>
|
|
714
|
+
<div class="rp-advanced-grid">
|
|
715
|
+
<div class="rp-field"><label>Tailscale IP</label><input id="rb-tsip" placeholder="auto-detected once connected"></div>
|
|
716
|
+
<div class="rp-field"><label>LAN IP</label><input id="rb-lanip" placeholder="auto-detected once connected"></div>
|
|
717
|
+
<div class="rp-field"><label>Motor server URL</label><input id="rb-motor" placeholder="http://...:5000"></div>
|
|
718
|
+
<div class="rp-field"><label>LiveKit URL</label><input id="rb-lk" placeholder="— uses the registered server above —"></div>
|
|
719
|
+
<div class="rp-field"><label>Voice stack override</label><select id="rb-voice"><option value="">— from character preset —</option></select></div>
|
|
720
|
+
</div>
|
|
721
|
+
</details>
|
|
481
722
|
</div>
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
<
|
|
486
|
-
|
|
487
|
-
<
|
|
488
|
-
<
|
|
489
|
-
|
|
723
|
+
|
|
724
|
+
<!-- Local runtime / Docker manager -->
|
|
725
|
+
<div class="rp-form" id="rp-runtime" style="grid-template-columns:1fr;">
|
|
726
|
+
<h3>Runtime health · scheduler · LiveKit · Docker</h3>
|
|
727
|
+
<div class="rp-quickrow">
|
|
728
|
+
<div class="rp-autonote" id="rp-runtime-summary" style="flex:1;margin:0;">not checked</div>
|
|
729
|
+
<button class="rp-btn" id="rp-runtime-refresh">↻ Refresh health</button>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="rp-tbl-wrap"><table class="rp-history-table"><thead><tr><th>Container</th><th>State</th><th>Image</th><th>Actions</th></tr></thead><tbody id="rp-runtime-containers"></tbody></table></div>
|
|
732
|
+
<div class="rp-autonote" id="rp-runtime-note">Docker controls are protected by the hub token. Logs are limited to the latest 100 lines.</div>
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
<!-- Config templates -->
|
|
736
|
+
<div class="rp-form" id="rp-templates" style="grid-template-columns:1fr 1fr; margin-top:1.2rem;">
|
|
737
|
+
<h3 style="grid-column:1/-1">Config templates</h3>
|
|
738
|
+
<div>
|
|
739
|
+
<h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Voice stacks</h4>
|
|
740
|
+
<div id="rp-voice-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
|
|
741
|
+
<div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
|
|
742
|
+
<input id="rp-newvoice-name" placeholder="new stack name" style="flex:1;min-width:0;">
|
|
743
|
+
<button class="rp-btn" id="rp-addvoice">Add</button>
|
|
744
|
+
</div>
|
|
745
|
+
<div class="rp-form" id="rp-voice-edit" style="display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
|
|
746
|
+
<h4 style="grid-column:1/-1;margin:0 0 0.2rem;">Edit voice stack: <span id="rpve-name-label"></span></h4>
|
|
747
|
+
<div class="rp-field"><label>STT provider</label><select id="rpve-stt-provider"><option value="speaches">speaches</option><option value="groq">groq</option><option value="vibevoice">vibevoice</option></select></div>
|
|
748
|
+
<div class="rp-field"><label>STT model</label><input id="rpve-stt-model"></div>
|
|
749
|
+
<div class="rp-field"><label>STT language</label><input id="rpve-stt-language" placeholder="en / auto"></div>
|
|
750
|
+
<div class="rp-field"><label>LLM provider</label><select id="rpve-llm-provider"><option value="ollama">ollama</option><option value="groq">groq</option><option value="gemini">gemini</option><option value="moltbot">moltbot</option></select></div>
|
|
751
|
+
<div class="rp-field"><label>LLM model</label><input id="rpve-llm-model"></div>
|
|
752
|
+
<div class="rp-field"><label>LLM API key</label><input type="password" id="rpve-llm-key" placeholder="(uses agent's own if blank)"></div>
|
|
753
|
+
<div class="rp-field"><label>Temperature</label><input id="rpve-temperature" type="number" step="0.1" value="0.7"></div>
|
|
754
|
+
<div class="rp-field"><label>Context size (num_ctx)</label><input id="rpve-num-ctx" type="number" value="8192"></div>
|
|
755
|
+
<div class="rp-field"><label>TTS provider</label><select id="rpve-tts-provider"><option value="elevenlabs">elevenlabs</option><option value="kokoro">kokoro</option><option value="piper">piper</option></select></div>
|
|
756
|
+
<div class="rp-field">
|
|
757
|
+
<label>TTS voice</label>
|
|
758
|
+
<div style="display:flex;gap:0.35rem;align-items:stretch;">
|
|
759
|
+
<select id="rpve-tts-voice" style="flex:1;min-width:0;"></select>
|
|
760
|
+
<button type="button" class="rp-btn" id="rpve-tts-voice-browse" title="Browse the full voice catalog for the selected provider" style="white-space:nowrap;">Browse…</button>
|
|
761
|
+
</div>
|
|
762
|
+
<div class="rp-autonote" id="rpve-tts-voice-note" style="margin-top:0.35rem;"></div>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="rp-field"><label>TTS language</label><input id="rpve-tts-language" placeholder="en"></div>
|
|
765
|
+
<div class="rp-field"><label>Max turns</label><input id="rpve-max-turns" type="number" value="20"></div>
|
|
766
|
+
<div class="rp-field"><label>Min endpointing delay (s)</label><input id="rpve-endpointing" type="number" step="0.1" value="0.7"></div>
|
|
767
|
+
<div class="rp-field"><label><input type="checkbox" id="rpve-interruptions" checked style="width:auto;margin-right:0.35rem;">Allow interruptions</label></div>
|
|
768
|
+
<div class="rp-field"><label><input type="checkbox" id="rpve-wake-enabled" style="width:auto;margin-right:0.35rem;">Wake word enabled</label></div>
|
|
769
|
+
<div class="rp-field"><label>Wake word model</label><input id="rpve-wake-model" placeholder="models/hey_jarvis.onnx"></div>
|
|
770
|
+
<div class="rp-field"><label>Wake threshold</label><input id="rpve-wake-threshold" type="number" step="0.05" value="0.5"></div>
|
|
771
|
+
<div class="rp-field"><label>Wake timeout (s)</label><input id="rpve-wake-timeout" type="number" step="0.5" value="3.0"></div>
|
|
772
|
+
<div style="grid-column:1/-1;display:flex;gap:0.4rem;">
|
|
773
|
+
<button class="rp-btn" id="rpve-save">Save</button>
|
|
774
|
+
<button class="rp-btn" id="rpve-cancel">Cancel</button>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
490
777
|
</div>
|
|
491
|
-
<div
|
|
492
|
-
<h4 style="margin:0 0 0.
|
|
493
|
-
<div
|
|
494
|
-
<div
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
<div class="rp-field" style="flex:1"><label>Pad X (0-1)</label><input id="rpce-nx" type="number" step="0.01" min="0" max="1"></div>
|
|
500
|
-
<div class="rp-field" style="flex:1"><label>Pad Y (0-1)</label><input id="rpce-ny" type="number" step="0.01" min="0" max="1"></div>
|
|
778
|
+
<div>
|
|
779
|
+
<h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Character presets</h4>
|
|
780
|
+
<div id="rp-char-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
|
|
781
|
+
<div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
|
|
782
|
+
<input id="rp-newchar-name" placeholder="new preset name" style="flex:1;min-width:0;">
|
|
783
|
+
<input id="rp-newchar-stack" list="rp-newchar-stack-list" placeholder="voice stack id" style="flex:1;min-width:0;">
|
|
784
|
+
<datalist id="rp-newchar-stack-list"></datalist>
|
|
785
|
+
<button class="rp-btn" id="rp-addchar">Add</button>
|
|
501
786
|
</div>
|
|
502
|
-
<div class="rp-
|
|
503
|
-
|
|
504
|
-
<
|
|
505
|
-
<
|
|
787
|
+
<div class="rp-form" id="rp-char-edit" style="display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
|
|
788
|
+
<h4 style="margin:0 0 0.2rem;">Edit character: <span id="rpce-name-label"></span></h4>
|
|
789
|
+
<div class="rp-field"><label>Name</label><input id="rpce-name"></div>
|
|
790
|
+
<div class="rp-field"><label>Description</label><input id="rpce-description"></div>
|
|
791
|
+
<div class="rp-field"><label>System prompt</label><textarea id="rpce-prompt" rows="4" style="font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;"></textarea></div>
|
|
792
|
+
<div class="rp-field"><label>Voice stack</label><select id="rpce-stack"></select></div>
|
|
793
|
+
<div class="rp-field"><label>Motors (comma-separated)</label><input id="rpce-motors" placeholder="drive, wave"></div>
|
|
794
|
+
<div style="display:flex;gap:0.4rem;">
|
|
795
|
+
<div class="rp-field" style="flex:1"><label>Pad X (0-1)</label><input id="rpce-nx" type="number" step="0.01" min="0" max="1"></div>
|
|
796
|
+
<div class="rp-field" style="flex:1"><label>Pad Y (0-1)</label><input id="rpce-ny" type="number" step="0.01" min="0" max="1"></div>
|
|
797
|
+
</div>
|
|
798
|
+
<div class="rp-field"><label>Avatar image path</label><input id="rpce-img" placeholder="/static/robopark/jaguar.png"></div>
|
|
799
|
+
<div style="display:flex;gap:0.4rem;">
|
|
800
|
+
<button class="rp-btn" id="rpce-save">Save</button>
|
|
801
|
+
<button class="rp-btn" id="rpce-cancel">Cancel</button>
|
|
802
|
+
</div>
|
|
506
803
|
</div>
|
|
507
804
|
</div>
|
|
508
805
|
</div>
|
|
509
806
|
</div>
|
|
510
807
|
</div>
|
|
511
808
|
|
|
809
|
+
<!-- Generic modal: title + scrollable body, used for session + audit detail views -->
|
|
810
|
+
<div class="rp-modal-back" id="rp-modal-back" role="dialog" aria-modal="true">
|
|
811
|
+
<div class="rp-modal" id="rp-modal">
|
|
812
|
+
<div class="rp-modal-head">
|
|
813
|
+
<h3 id="rp-modal-title">Details</h3>
|
|
814
|
+
<button class="x" id="rp-modal-close" aria-label="Close">✕</button>
|
|
815
|
+
</div>
|
|
816
|
+
<div class="rp-modal-body" id="rp-modal-body"></div>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
512
820
|
<footer class="cmd">
|
|
513
821
|
<span class="sel">on <b id="selName">this node</b></span>
|
|
514
822
|
<input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
|
|
@@ -526,12 +834,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
526
834
|
var rpReadOnly = false, pmBusy = false;
|
|
527
835
|
var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
|
|
528
836
|
var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
|
|
837
|
+
var schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory
|
|
838
|
+
var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)
|
|
529
839
|
var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)
|
|
530
840
|
var FALLBACK_SLOTS = [
|
|
531
841
|
{label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
|
|
532
842
|
{label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},
|
|
533
843
|
{label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},
|
|
534
|
-
{label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.
|
|
844
|
+
{label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.40, ny:0.68, sub:'BMW', img:'/static/robopark/bmw.png'},
|
|
535
845
|
{label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}
|
|
536
846
|
];
|
|
537
847
|
var parkSlots = FALLBACK_SLOTS.slice();
|
|
@@ -771,20 +1081,75 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
771
1081
|
$('offline').style.display = (!ok && everOk) ? 'block' : 'none';
|
|
772
1082
|
}
|
|
773
1083
|
|
|
1084
|
+
// Scheduler-enrolled robots are not necessarily infinicode mesh peers. Add
|
|
1085
|
+
// them to the same Fleet/Park collection so a robot running only the
|
|
1086
|
+
// preview agent is still visible and selectable in the full dashboard.
|
|
1087
|
+
function getJson(path){
|
|
1088
|
+
// QS is "?token=..."; a path that already carries its own query string
|
|
1089
|
+
// (e.g. "/robopark/api/sessions?active_only=false&limit=200") needs "&"
|
|
1090
|
+
// instead, or the token param lands inside an invalid second "?" and
|
|
1091
|
+
// the request goes out unauthenticated -> 401.
|
|
1092
|
+
var url = path + (path.indexOf('?')>=0 ? QS.replace('?','&') : QS);
|
|
1093
|
+
return fetch(url, {cache:'no-store'}).then(function(r){
|
|
1094
|
+
if(!r.ok) throw new Error(path+' '+r.status);
|
|
1095
|
+
return r.json();
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function mergeSchedulerRobots(nodes){
|
|
1099
|
+
return Promise.all([getJson('/robopark/api/robots'), getJson('/robopark/api/devices')]).then(function(data){
|
|
1100
|
+
var rows = data[0], devices = data[1];
|
|
1101
|
+
var deviceById = {}, deviceByName = {};
|
|
1102
|
+
(Array.isArray(devices)?devices:[]).forEach(function(device){ deviceById[device.id] = device; if(device.name) deviceByName[device.name] = device; });
|
|
1103
|
+
schedulerDevicesById = deviceById;
|
|
1104
|
+
schedulerDevicesByName = deviceByName;
|
|
1105
|
+
var merged = (nodes||[]).slice();
|
|
1106
|
+
(Array.isArray(rows)?rows:[]).forEach(function(row){
|
|
1107
|
+
var id = row.id || row.robot_id;
|
|
1108
|
+
if(!id) return;
|
|
1109
|
+
var name = row.name || id;
|
|
1110
|
+
var duplicate = merged.some(function(n){
|
|
1111
|
+
return n.schedulerRobotId===id || n.displayName===name || n.nodeId===id;
|
|
1112
|
+
});
|
|
1113
|
+
if(duplicate) return;
|
|
1114
|
+
var device = deviceById[id] || {};
|
|
1115
|
+
var status = String(device.status || row.status || '').toLowerCase();
|
|
1116
|
+
var connected = ['online','detecting','connecting','running'].indexOf(status)>=0;
|
|
1117
|
+
var keys = [String(id).toLowerCase(), String(name).toLowerCase()];
|
|
1118
|
+
merged.push({
|
|
1119
|
+
nodeId: 'scheduler-robot-'+id,
|
|
1120
|
+
schedulerRobotId: id,
|
|
1121
|
+
displayName: name,
|
|
1122
|
+
role: 'robot',
|
|
1123
|
+
platform: 'RoboPark robot',
|
|
1124
|
+
version: 'scheduler',
|
|
1125
|
+
capabilities: ['camera','microphone','voice'],
|
|
1126
|
+
connected: connected,
|
|
1127
|
+
load: 0,
|
|
1128
|
+
lastSeenAt: device.last_heartbeat ? Date.parse(device.last_heartbeat) : (row.last_heartbeat ? Date.parse(row.last_heartbeat) : null),
|
|
1129
|
+
telemKeys: keys,
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
return merged;
|
|
1133
|
+
}).catch(function(){ return nodes||[]; });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
774
1136
|
function poll(){
|
|
775
1137
|
fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){
|
|
776
1138
|
if(!r.ok) throw new Error('status '+r.status);
|
|
777
1139
|
return r.json();
|
|
778
1140
|
}).then(function(s){
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1141
|
+
return mergeSchedulerRobots(s.nodes||[]).then(function(nodes){
|
|
1142
|
+
s.nodes = nodes;
|
|
1143
|
+
everOk = true; setLive(true); lastStatus = s;
|
|
1144
|
+
if(!selfId && s.self) selfId = s.self.nodeId;
|
|
1145
|
+
if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
|
|
1146
|
+
updateHw(s);
|
|
1147
|
+
renderSummary(s);
|
|
1148
|
+
renderFleet(s.nodes||[]);
|
|
1149
|
+
renderHw(s.hardware);
|
|
1150
|
+
renderFeed(s.activity||[]);
|
|
1151
|
+
Park.onStatus(s);
|
|
1152
|
+
});
|
|
788
1153
|
}).catch(function(){ setLive(false); });
|
|
789
1154
|
}
|
|
790
1155
|
|
|
@@ -839,7 +1204,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
839
1204
|
$('pane-park').classList.toggle('active', name==='park');
|
|
840
1205
|
$('pane-robopark').classList.toggle('active', name==='robopark');
|
|
841
1206
|
if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
|
|
842
|
-
if (name==='robopark')
|
|
1207
|
+
if (name==='robopark'){
|
|
1208
|
+
pollRobopark();
|
|
1209
|
+
initRpSubTabs();
|
|
1210
|
+
}
|
|
843
1211
|
if (name==='park') Park.activate(); else Park.deactivate();
|
|
844
1212
|
}
|
|
845
1213
|
var initHash = location.hash.replace('#','');
|
|
@@ -909,11 +1277,41 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
909
1277
|
showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
|
|
910
1278
|
}).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
|
|
911
1279
|
}
|
|
1280
|
+
|
|
1281
|
+
function runtimeDockerAction(action, container){
|
|
1282
|
+
if(!container) return;
|
|
1283
|
+
if(!confirm(action+' '+container+'?')) return;
|
|
1284
|
+
rpAction('POST','/robopark/runtime/docker',{action:action,container:container}).then(function(res){ if(action==='logs'){ showOut(container+' logs (latest 100 lines):\\n'+(res&&res.output||'(no logs)')); } else { showOut(action+' queued for '+container); pollRuntimeHealth(); } }).catch(function(e){ showOut('docker '+action+' ('+container+'): '+e.message); });
|
|
1285
|
+
}
|
|
1286
|
+
function pollRuntimeHealth(){
|
|
1287
|
+
var summary=$('rp-runtime-summary'), tbody=$('rp-runtime-containers'); if(!summary||!tbody) return;
|
|
1288
|
+
getJson('/robopark/runtime/health').then(function(h){
|
|
1289
|
+
var docker=h.docker||{}, scheduler=h.scheduler||{};
|
|
1290
|
+
var containers=Array.isArray(h.containers&&h.containers.data)?h.containers.data:[];
|
|
1291
|
+
summary.textContent=(docker.ok?'Docker connected':'Docker unavailable')+' · '+(scheduler.ok?'scheduler reachable':'scheduler unavailable')+' · '+containers.filter(function(c){return /^(up|running)/i.test(String(c.State||''));}).length+'/'+containers.length+' containers running';
|
|
1292
|
+
summary.style.color=(docker.ok&&scheduler.ok)?'var(--ok)':'var(--bad)';
|
|
1293
|
+
tbody.innerHTML=containers.length?containers.map(function(c){
|
|
1294
|
+
var name=c.Names||c.Name||'container', state=c.State||'unknown', running=/^(up|running)/i.test(state);
|
|
1295
|
+
return '<tr><td class="mono">'+esc(name)+'</td><td>'+esc(state)+'</td><td class="mono">'+esc(c.Image||'—')+'</td><td class="space-x-2">'+['start','stop','restart','logs'].map(function(a){return '<button class="rp-btn '+(a==='stop'?'danger':'')+'" data-runtime-action="'+a+'" data-runtime-container="'+esc(name)+'"'+((a==='start'&&running)||(a==='stop'&&!running)?' disabled':'')+'>'+a+'</button>';}).join(' ')+'</td></tr>';
|
|
1296
|
+
}).join(''):'<tr><td colspan="4">No Docker containers found.</td></tr>';
|
|
1297
|
+
if(!docker.ok) $('rp-runtime-note').textContent='Docker unavailable: '+(docker.error||'check Docker Desktop');
|
|
1298
|
+
}).catch(function(e){ summary.textContent='runtime health unavailable'; summary.style.color='var(--bad)'; });
|
|
1299
|
+
}
|
|
1300
|
+
var runtimeRefresh=$('rp-runtime-refresh'); if(runtimeRefresh) runtimeRefresh.addEventListener('click', pollRuntimeHealth);
|
|
1301
|
+
var runtimeTable=$('rp-runtime-containers'); if(runtimeTable) runtimeTable.addEventListener('click', function(e){ var b=e.target.closest('[data-runtime-action]'); if(b&&!b.disabled) runtimeDockerAction(b.dataset.runtimeAction,b.dataset.runtimeContainer); });
|
|
1302
|
+
function recoverRobot(id){
|
|
1303
|
+
if(!confirm('Recover '+id+'? This closes any stale LiveKit session and returns the robot to idle.')) return;
|
|
1304
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/recover',{}).then(function(res){
|
|
1305
|
+
showOut('recovered '+id+' — queued triggers cleared and robot returned to idle');
|
|
1306
|
+
pollRobopark(); Park.refreshDrawer();
|
|
1307
|
+
}).catch(function(e){ showOut('recover ('+id+'): '+e.message); });
|
|
1308
|
+
}
|
|
912
1309
|
$('rp-grid').addEventListener('click', function(e){
|
|
913
1310
|
var b=e.target.closest?e.target.closest('.rp-btn'):null;
|
|
914
1311
|
if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
|
|
915
1312
|
if(act==='end') endSession(id);
|
|
916
1313
|
if(act==='trigger') triggerSession(id);
|
|
1314
|
+
if(act==='recover') recoverRobot(id);
|
|
917
1315
|
if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }
|
|
918
1316
|
return;
|
|
919
1317
|
}
|
|
@@ -932,10 +1330,115 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
932
1330
|
|
|
933
1331
|
var characterPresets=[];
|
|
934
1332
|
function loadCharacterPresets(){
|
|
935
|
-
fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});
|
|
1333
|
+
fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); refreshDrawerPresetOptions(); }).catch(function(){});
|
|
936
1334
|
}
|
|
937
1335
|
function loadVoiceStacks(){
|
|
938
|
-
fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});
|
|
1336
|
+
fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); refreshDrawerPresetOptions(); }).catch(function(){});
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ── TTS voice catalog (per provider) ──
|
|
1340
|
+
// Holds the dropdown options for whichever TTS provider is currently
|
|
1341
|
+
// selected in the voice-stack editor. We cache the catalog per-provider
|
|
1342
|
+
// in ttsVoiceCache so flipping back and forth between kokoro/elevenlabs
|
|
1343
|
+
// is instant. Each entry is { provider, source, count, voices, warning }.
|
|
1344
|
+
var ttsVoiceCache = {}; // provider -> TtsVoiceList payload
|
|
1345
|
+
var ttsVoiceLoading = {}; // provider -> in-flight Promise (de-dupes concurrent loads)
|
|
1346
|
+
function loadTtsVoiceCatalog(provider, opts){
|
|
1347
|
+
if(!provider) return Promise.resolve(null);
|
|
1348
|
+
opts = opts || {};
|
|
1349
|
+
var force = !!opts.force;
|
|
1350
|
+
if(ttsVoiceCache[provider] && !force) return Promise.resolve(ttsVoiceCache[provider]);
|
|
1351
|
+
if(ttsVoiceLoading[provider] && !force) return ttsVoiceLoading[provider];
|
|
1352
|
+
var p = fetch('/robopark/api/voices/'+encodeURIComponent(provider)+QS,{cache:'no-store'})
|
|
1353
|
+
.then(function(r){ return r.json(); })
|
|
1354
|
+
.then(function(d){
|
|
1355
|
+
ttsVoiceCache[provider] = d || {provider:provider, source:'static', count:0, voices:[], warning:'Empty response'};
|
|
1356
|
+
return ttsVoiceCache[provider];
|
|
1357
|
+
})
|
|
1358
|
+
.catch(function(e){
|
|
1359
|
+
var fallback = {provider:provider, source:'static', count:0, voices:[], warning:'Could not reach scheduler: '+(e&&e.message||e)};
|
|
1360
|
+
ttsVoiceCache[provider] = fallback;
|
|
1361
|
+
return fallback;
|
|
1362
|
+
})
|
|
1363
|
+
.then(function(v){ ttsVoiceLoading[provider]=null; return v; });
|
|
1364
|
+
ttsVoiceLoading[provider] = p;
|
|
1365
|
+
return p;
|
|
1366
|
+
}
|
|
1367
|
+
// Build the option list for the inline TTS-voice <select>. The current
|
|
1368
|
+
// value (s.tts_voice) is always included as a synthetic option if it
|
|
1369
|
+
// isn't in the catalog — that way editing a stack that was saved with a
|
|
1370
|
+
// legacy / unknown voice still shows what the user actually had.
|
|
1371
|
+
function fillTtsVoiceSelect(provider, currentVoiceId){
|
|
1372
|
+
var sel = $('rpve-tts-voice');
|
|
1373
|
+
var note = $('rpve-tts-voice-note');
|
|
1374
|
+
if(!sel) return;
|
|
1375
|
+
sel.innerHTML = '';
|
|
1376
|
+
var cache = ttsVoiceCache[provider];
|
|
1377
|
+
if(!cache){
|
|
1378
|
+
sel.innerHTML = '<option value="">— loading voices… —</option>';
|
|
1379
|
+
if(note){ note.className='rp-autonote busy'; note.textContent = 'Loading '+provider+' voice catalog…'; }
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
var voices = Array.isArray(cache.voices) ? cache.voices : [];
|
|
1383
|
+
if(!voices.length){
|
|
1384
|
+
var ph = currentVoiceId
|
|
1385
|
+
? '<option value="'+esc(currentVoiceId)+'" selected>'+esc(currentVoiceId)+' (current)</option>'
|
|
1386
|
+
: '<option value="">— no voices available —</option>';
|
|
1387
|
+
sel.innerHTML = ph;
|
|
1388
|
+
} else {
|
|
1389
|
+
var html = '';
|
|
1390
|
+
var seen = {};
|
|
1391
|
+
for(var i=0;i<voices.length;i++){
|
|
1392
|
+
var v = voices[i];
|
|
1393
|
+
var id = v.id || v.voice_id || '';
|
|
1394
|
+
if(!id || seen[id]) continue;
|
|
1395
|
+
seen[id] = 1;
|
|
1396
|
+
var label = v.name || id;
|
|
1397
|
+
var subbits = [];
|
|
1398
|
+
if(v.language) subbits.push(v.language);
|
|
1399
|
+
if(v.gender) subbits.push(v.gender);
|
|
1400
|
+
if(v.category) subbits.push(v.category);
|
|
1401
|
+
if(subbits.length) label = label + ' · ' + subbits.join(' · ');
|
|
1402
|
+
html += '<option value="'+esc(id)+'"'+(id===currentVoiceId?' selected':'')+'>'+esc(label)+'</option>';
|
|
1403
|
+
}
|
|
1404
|
+
// Preserve an out-of-catalog current value so the user can still see
|
|
1405
|
+
// what was previously saved.
|
|
1406
|
+
if(currentVoiceId && !seen[currentVoiceId]){
|
|
1407
|
+
html += '<option value="'+esc(currentVoiceId)+'" selected>'+esc(currentVoiceId)+' (saved value, not in catalog)</option>';
|
|
1408
|
+
}
|
|
1409
|
+
sel.innerHTML = html;
|
|
1410
|
+
}
|
|
1411
|
+
// status note (source + warning)
|
|
1412
|
+
if(note){
|
|
1413
|
+
var src = cache.source || 'static';
|
|
1414
|
+
var cnt = cache.count != null ? cache.count : voices.length;
|
|
1415
|
+
if(cache.warning){
|
|
1416
|
+
note.className='rp-autonote bad';
|
|
1417
|
+
note.textContent = '⚠ '+cache.warning+(cnt?(' — showing '+cnt+' voices.'):'');
|
|
1418
|
+
} else {
|
|
1419
|
+
note.className='rp-autonote';
|
|
1420
|
+
note.textContent = cnt+' '+provider+' voices · source: '+src+(currentVoiceId?(' · current: '+currentVoiceId):'');
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function refreshTtsVoiceSelect(force){
|
|
1425
|
+
var provEl = $('rpve-tts-provider');
|
|
1426
|
+
var sel = $('rpve-tts-voice');
|
|
1427
|
+
if(!provEl || !sel) return;
|
|
1428
|
+
var provider = provEl.value || 'elevenlabs';
|
|
1429
|
+
var cur = sel.value || (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].value) || '';
|
|
1430
|
+
// Prefer the live value from the input field if the user has been
|
|
1431
|
+
// editing it. openVoiceStackEdit sets it before this runs, so .value
|
|
1432
|
+
// is the saved voice. The above cur is only used as a fallback.
|
|
1433
|
+
var savedVoice = (sel && sel.__savedVoice) || cur;
|
|
1434
|
+
if(force || !ttsVoiceCache[provider]){
|
|
1435
|
+
fillTtsVoiceSelect(provider, savedVoice);
|
|
1436
|
+
loadTtsVoiceCatalog(provider, {force:!!force}).then(function(){
|
|
1437
|
+
fillTtsVoiceSelect(provider, savedVoice);
|
|
1438
|
+
});
|
|
1439
|
+
} else {
|
|
1440
|
+
fillTtsVoiceSelect(provider, savedVoice);
|
|
1441
|
+
}
|
|
939
1442
|
}
|
|
940
1443
|
function loadParkSlots(){
|
|
941
1444
|
fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
|
|
@@ -976,6 +1479,45 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
976
1479
|
}
|
|
977
1480
|
}
|
|
978
1481
|
}
|
|
1482
|
+
// Rebuild the option list inside the currently-open Park robot drawer so
|
|
1483
|
+
// preset/voice dropdowns populate even if the data finishes loading AFTER
|
|
1484
|
+
// the drawer was opened. Idempotent: no-op if the drawer is closed.
|
|
1485
|
+
function refreshDrawerPresetOptions(){
|
|
1486
|
+
var b=$('pk-body'); if(!b) return;
|
|
1487
|
+
var setupChar=$('pk-setup-char');
|
|
1488
|
+
if(setupChar){
|
|
1489
|
+
var curSetup=setupChar.value;
|
|
1490
|
+
setupChar.innerHTML='<option value="">— auto —</option>'+characterPresets.map(function(p){
|
|
1491
|
+
var id=p.id||p.preset_id||p.character_id||'';
|
|
1492
|
+
var name=(p.name||p.id||p.preset_id||'preset')+(p.description?(' — '+p.description):'');
|
|
1493
|
+
return '<option value="'+esc(id)+'"'+(id===curSetup?' selected':'')+'>'+esc(name)+'</option>';
|
|
1494
|
+
}).join('');
|
|
1495
|
+
}
|
|
1496
|
+
var setupVoice=$('pk-setup-voice');
|
|
1497
|
+
if(setupVoice){
|
|
1498
|
+
var curVoice=setupVoice.value;
|
|
1499
|
+
setupVoice.innerHTML='<option value="">— from character preset —</option>'+voiceStacks.map(function(s){
|
|
1500
|
+
var id=s.id||''; var name=(s.name||s.id||'stack')+(s.tts_voice?(' · '+s.tts_voice):'');
|
|
1501
|
+
return '<option value="'+esc(id)+'"'+(id===curVoice?' selected':'')+'>'+esc(name)+'</option>';
|
|
1502
|
+
}).join('');
|
|
1503
|
+
}
|
|
1504
|
+
var effChar=$('pk-eff-char');
|
|
1505
|
+
if(effChar){
|
|
1506
|
+
var curEffChar=effChar.value;
|
|
1507
|
+
effChar.innerHTML=characterPresets.map(function(p){
|
|
1508
|
+
var id=p.id||p.preset_id||p.character_id||''; var name=p.name||p.id||p.preset_id||'';
|
|
1509
|
+
return '<option value="'+esc(id)+'"'+(id===curEffChar?' selected':'')+'>'+esc(name)+'</option>';
|
|
1510
|
+
}).join('');
|
|
1511
|
+
}
|
|
1512
|
+
var effVoice=$('pk-eff-voice');
|
|
1513
|
+
if(effVoice){
|
|
1514
|
+
var curEffVoice=effVoice.value;
|
|
1515
|
+
effVoice.innerHTML=voiceStacks.map(function(s){
|
|
1516
|
+
var id=s.id||''; var name=s.name||s.id||'';
|
|
1517
|
+
return '<option value="'+esc(id)+'"'+(id===curEffVoice?' selected':'')+'>'+esc(name)+'</option>';
|
|
1518
|
+
}).join('');
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
979
1521
|
|
|
980
1522
|
function slugify(s){ return (s||'').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,40); }
|
|
981
1523
|
|
|
@@ -1058,7 +1600,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1058
1600
|
function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; refreshRobotCliCommand(); }
|
|
1059
1601
|
var srvDetectBtn=$('srv-detect'); if(srvDetectBtn) srvDetectBtn.addEventListener('click', detectLocalLiveKit);
|
|
1060
1602
|
function triggerSession(id){
|
|
1061
|
-
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger
|
|
1603
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/simulate-trigger',{}).then(function(res){
|
|
1604
|
+
if(res && res.queued===false) throw new Error(res.reason||'trigger was not queued');
|
|
1605
|
+
showOut('motion trigger queued for '+id+' — waiting for robot heartbeat'); pollRobopark(); Park.refreshDrawer();
|
|
1606
|
+
}).catch(function(e){ showOut('motion trigger ('+id+'): '+e.message); });
|
|
1062
1607
|
}
|
|
1063
1608
|
function updateRobotCharacter(id, cid){
|
|
1064
1609
|
if(!cid){ showOut('Choose a character preset'); return; }
|
|
@@ -1111,7 +1656,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1111
1656
|
$('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);
|
|
1112
1657
|
$('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);
|
|
1113
1658
|
$('rpve-tts-provider').value=s.tts_provider||'elevenlabs';
|
|
1114
|
-
|
|
1659
|
+
// Stash the saved voice so refreshTtsVoiceSelect can keep it selected
|
|
1660
|
+
// when the catalog hasn't loaded yet. fillTtsVoiceSelect reads this
|
|
1661
|
+
// and includes it as a synthetic option if the catalog is missing it.
|
|
1662
|
+
var sel = $('rpve-tts-voice'); if(sel) sel.__savedVoice = s.tts_voice || '';
|
|
1663
|
+
refreshTtsVoiceSelect(true);
|
|
1115
1664
|
$('rpve-tts-language').value=s.tts_language||'en';
|
|
1116
1665
|
$('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);
|
|
1117
1666
|
$('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);
|
|
@@ -1140,6 +1689,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1140
1689
|
var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });
|
|
1141
1690
|
var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);
|
|
1142
1691
|
var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });
|
|
1692
|
+
// TTS provider change: re-populate the voice <select> for the new provider.
|
|
1693
|
+
// We pass force=true so the catalog is refetched if we haven't seen this
|
|
1694
|
+
// provider yet; otherwise we use the cached list to repopulate instantly.
|
|
1695
|
+
var ttsProvEl = $('rpve-tts-provider');
|
|
1696
|
+
if(ttsProvEl) ttsProvEl.addEventListener('change', function(){
|
|
1697
|
+
var sel = $('rpve-tts-voice');
|
|
1698
|
+
if(sel) sel.__savedVoice = sel.value || sel.__savedVoice || '';
|
|
1699
|
+
refreshTtsVoiceSelect(true);
|
|
1700
|
+
});
|
|
1701
|
+
// "Browse…" button: open the voice picker modal for the current provider.
|
|
1702
|
+
var ttsBrowseBtn = $('rpve-tts-voice-browse');
|
|
1703
|
+
if(ttsBrowseBtn) ttsBrowseBtn.addEventListener('click', function(){
|
|
1704
|
+
var provEl = $('rpve-tts-provider');
|
|
1705
|
+
var provider = (provEl && provEl.value) || 'elevenlabs';
|
|
1706
|
+
openTtsVoicePicker(provider, function(voiceId){
|
|
1707
|
+
var sel = $('rpve-tts-voice');
|
|
1708
|
+
if(!sel) return;
|
|
1709
|
+
// Make sure the chosen id exists as an option (it may have been
|
|
1710
|
+
// absent from the inline dropdown because the catalog hadn't loaded
|
|
1711
|
+
// yet). If it isn't there, append a synthetic option and select it.
|
|
1712
|
+
var exists = false;
|
|
1713
|
+
for(var i=0;i<sel.options.length;i++){ if(sel.options[i].value === voiceId){ exists=true; break; } }
|
|
1714
|
+
if(!exists){
|
|
1715
|
+
var o = document.createElement('option'); o.value=voiceId; o.textContent=voiceId+' (selected via Browse)';
|
|
1716
|
+
sel.appendChild(o);
|
|
1717
|
+
}
|
|
1718
|
+
sel.value = voiceId;
|
|
1719
|
+
sel.__savedVoice = voiceId;
|
|
1720
|
+
var note = $('rpve-tts-voice-note');
|
|
1721
|
+
if(note){ note.className='rp-autonote ok'; note.textContent='✓ selected '+voiceId+' from the '+provider+' catalog.'; }
|
|
1722
|
+
});
|
|
1723
|
+
});
|
|
1143
1724
|
|
|
1144
1725
|
// ── character-preset edit panel ──
|
|
1145
1726
|
var editingCharPresetId=null;
|
|
@@ -1193,6 +1774,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1193
1774
|
// stash per-robot rows so the Park drawer can show live session stats
|
|
1194
1775
|
roboparkByName = {};
|
|
1195
1776
|
robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
|
|
1777
|
+
// push the same map into the Park so the canvas can color robots
|
|
1778
|
+
// by live/talking/waiting/ended status. updateTelemetry also
|
|
1779
|
+
// re-derives the readiness → status mapping for every node.
|
|
1780
|
+
if(Park && Park.updateTelemetry) Park.updateTelemetry(roboparkByName);
|
|
1196
1781
|
if (d && (d.disabled || d.ok===false)){
|
|
1197
1782
|
totals.innerHTML=''; $('pm-wrap').hidden=true;
|
|
1198
1783
|
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://<scheduler>:8080</span></div>';
|
|
@@ -1215,7 +1800,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1215
1800
|
var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
|
|
1216
1801
|
var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
|
|
1217
1802
|
var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
|
|
1218
|
-
var active = x.active_session||x.in_session||x.session_active;
|
|
1803
|
+
var active = x.active_session||x.in_session||x.session_active||x.current_session_id||x.status==='connecting'||x.status==='running';
|
|
1804
|
+
var readiness = x.readiness_code||'unknown';
|
|
1805
|
+
var readinessBad = readiness!=='ready' && readiness!=='in_session';
|
|
1806
|
+
var readinessClass = readiness==='ready'?'ok':(readiness==='in_session'?'':'bad');
|
|
1219
1807
|
var idAttr = esc(name);
|
|
1220
1808
|
html += '<div class="rp-card" data-name="'+esc(name)+'"><div class="rp-card-top">'+(avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')+'<div style="min-width:0;flex:1;"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4></div></div><div class="rp-metrics">'
|
|
1221
1809
|
+ '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
|
|
@@ -1227,6 +1815,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1227
1815
|
+ '<button class="rp-btn" data-act="preview" data-robot="'+idAttr+'">Preview</button>'
|
|
1228
1816
|
+ '<button class="rp-btn danger" data-act="end" data-robot="'+idAttr+'"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'
|
|
1229
1817
|
+ '</div>'
|
|
1818
|
+
+ '<div class="pk-note '+readinessClass+'" style="margin-top:0.5rem;color:'+(readinessBad?'var(--bad)':'var(--ok)')+'">'+esc(readiness.replace(/_/g,' '))+' · '+esc(x.readiness_message||'no readiness detail')+'</div>'
|
|
1819
|
+
+ (readiness==='stuck_session'||readiness==='stale_session'||readiness==='join_timeout'?'<div class="rp-actions"><button class="rp-btn danger" data-act="recover" data-robot="'+idAttr+'"'+(rpReadOnly?' disabled':'')+'>Recover robot</button></div>':'')
|
|
1230
1820
|
+ '<div class="rp-field" style="margin-top:0.45rem"><label style="font-size:0.64rem">Character preset</label><select class="rp-char" data-act="char" data-robot="'+idAttr+'"><option value="">'+esc(x.character_id||x.character_preset_id||'— auto —')+'</option></select></div></div>';
|
|
1231
1821
|
}
|
|
1232
1822
|
grid.innerHTML = html;
|
|
@@ -1238,6 +1828,836 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1238
1828
|
});
|
|
1239
1829
|
}
|
|
1240
1830
|
|
|
1831
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1832
|
+
// Sessions & Telemetry — sub-tab control room
|
|
1833
|
+
// Six sub-tabs share a single pane-robopark pane:
|
|
1834
|
+
// Overview · Live Sessions · History · Audit Log · Fleet · Setup
|
|
1835
|
+
// Each sub-tab owns a poll function + a setInterval handle; the handles
|
|
1836
|
+
// are cleared on switch so the browser only fetches when its pane is
|
|
1837
|
+
// actually visible.
|
|
1838
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1839
|
+
|
|
1840
|
+
// ── shared time / formatting helpers ──
|
|
1841
|
+
function rpFmtRelative(ts){
|
|
1842
|
+
if(!ts) return '—';
|
|
1843
|
+
var t=typeof ts==='number'?ts:Date.parse(ts);
|
|
1844
|
+
if(!t||isNaN(t)) return '—';
|
|
1845
|
+
var diff=Math.floor((Date.now()-t)/1000);
|
|
1846
|
+
if(diff<0) diff=0;
|
|
1847
|
+
if(diff<5) return 'just now';
|
|
1848
|
+
if(diff<60) return diff+'s ago';
|
|
1849
|
+
if(diff<3600) return Math.floor(diff/60)+'m ago';
|
|
1850
|
+
if(diff<86400) return Math.floor(diff/3600)+'h ago';
|
|
1851
|
+
return Math.floor(diff/86400)+'d ago';
|
|
1852
|
+
}
|
|
1853
|
+
function rpFmtAbs(ts){
|
|
1854
|
+
if(!ts) return '—';
|
|
1855
|
+
var t=typeof ts==='number'?ts:Date.parse(ts);
|
|
1856
|
+
if(!t||isNaN(t)) return '—';
|
|
1857
|
+
var d=new Date(t);
|
|
1858
|
+
function p(n){return (n<10?'0':'')+n;}
|
|
1859
|
+
return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
|
|
1860
|
+
}
|
|
1861
|
+
function rpFmtDuration(sec){
|
|
1862
|
+
if(sec==null||isNaN(sec)) return '—';
|
|
1863
|
+
sec=Math.max(0,Math.floor(sec));
|
|
1864
|
+
var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
|
|
1865
|
+
if(h) return h+'h '+m+'m';
|
|
1866
|
+
if(m) return m+'m '+s+'s';
|
|
1867
|
+
return s+'s';
|
|
1868
|
+
}
|
|
1869
|
+
function rpEndReasonClass(r){
|
|
1870
|
+
var x=String(r||'').toLowerCase();
|
|
1871
|
+
if(x==='operator'||x==='end_session'||x==='manual') return 'operator';
|
|
1872
|
+
if(x==='error'||x==='abnormal'||x==='failed') return 'error';
|
|
1873
|
+
if(x==='timeout'||x==='silence_timeout') return 'timeout';
|
|
1874
|
+
if(x==='silence') return 'silence';
|
|
1875
|
+
if(x==='completed'||x==='normal'||x==='done') return 'completed';
|
|
1876
|
+
return '';
|
|
1877
|
+
}
|
|
1878
|
+
function rpCategoryIcon(cat){
|
|
1879
|
+
var c=String(cat||'').toLowerCase();
|
|
1880
|
+
if(c==='robot') return '🤖';
|
|
1881
|
+
if(c==='device') return '📡';
|
|
1882
|
+
if(c==='session') return '🎙';
|
|
1883
|
+
if(c==='server') return '🖥';
|
|
1884
|
+
if(c==='voice') return '🗣';
|
|
1885
|
+
if(c==='character') return '🎭';
|
|
1886
|
+
if(c==='preview') return '👁';
|
|
1887
|
+
if(c==='settings') return '⚙';
|
|
1888
|
+
return '✦';
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// ── Sub-tab routing ──
|
|
1892
|
+
var rpSubTab = 'overview';
|
|
1893
|
+
var rpIntervals = {}; // sub-tab name -> setInterval handle
|
|
1894
|
+
var rpHistoryAll = []; // full recent-session list (we paginate client-side)
|
|
1895
|
+
function setRpSubTab(name){
|
|
1896
|
+
if(!name) return;
|
|
1897
|
+
rpSubTab = name;
|
|
1898
|
+
var btns = document.querySelectorAll('.rp-subtab');
|
|
1899
|
+
for(var i=0;i<btns.length;i++){
|
|
1900
|
+
var b=btns[i], sub=b.getAttribute('data-sub');
|
|
1901
|
+
var isActive = sub===name;
|
|
1902
|
+
b.classList.toggle('active', isActive);
|
|
1903
|
+
b.setAttribute('aria-selected', isActive?'true':'false');
|
|
1904
|
+
}
|
|
1905
|
+
var subs = document.querySelectorAll('.rp-sub');
|
|
1906
|
+
for(var j=0;j<subs.length;j++){
|
|
1907
|
+
var s=subs[j];
|
|
1908
|
+
s.classList.toggle('active', s.id==='rp-sub-'+name);
|
|
1909
|
+
}
|
|
1910
|
+
// stop every sub-tab interval, then start only the active one
|
|
1911
|
+
var names = ['overview','live','history','audit','fleet','setup'];
|
|
1912
|
+
for(var k=0;k<names.length;k++){
|
|
1913
|
+
var n=names[k];
|
|
1914
|
+
if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }
|
|
1915
|
+
}
|
|
1916
|
+
if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }
|
|
1917
|
+
if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }
|
|
1918
|
+
if(name==='history'){ rpHistoryPage=0; pollHistory(); }
|
|
1919
|
+
if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }
|
|
1920
|
+
if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }
|
|
1921
|
+
if(name==='setup'){ pollSettings(); loadCharacterPresets(); loadVoiceStacks(); pollRuntimeHealth(); }
|
|
1922
|
+
// remember last sub-tab in URL hash
|
|
1923
|
+
try{ history.replaceState(null,'','#robopark/'+name); }catch(e){}
|
|
1924
|
+
}
|
|
1925
|
+
function buildRpSubTabs(){
|
|
1926
|
+
var btns = document.querySelectorAll('.rp-subtab');
|
|
1927
|
+
for(var i=0;i<btns.length;i++){
|
|
1928
|
+
(function(btn){
|
|
1929
|
+
btn.addEventListener('click', function(){ setRpSubTab(btn.getAttribute('data-sub')); });
|
|
1930
|
+
})(btns[i]);
|
|
1931
|
+
}
|
|
1932
|
+
// restore from hash like #robopark/history
|
|
1933
|
+
var h=String(location.hash||'').replace('#','');
|
|
1934
|
+
var m = h.match(/^robopark\\/(.+)$/);
|
|
1935
|
+
var initial = (m && m[1]) ? m[1] : 'overview';
|
|
1936
|
+
if(['overview','live','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
|
|
1937
|
+
setRpSubTab(initial);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// ── Modal helper ──
|
|
1941
|
+
function showModal(title, bodyHtml){
|
|
1942
|
+
var back = $('rp-modal-back');
|
|
1943
|
+
if(!back) return;
|
|
1944
|
+
$('rp-modal-title').textContent = title || 'Details';
|
|
1945
|
+
$('rp-modal-body').innerHTML = bodyHtml || '';
|
|
1946
|
+
back.classList.add('open');
|
|
1947
|
+
}
|
|
1948
|
+
function closeModal(){
|
|
1949
|
+
var back = $('rp-modal-back');
|
|
1950
|
+
if(!back) return;
|
|
1951
|
+
back.classList.remove('open');
|
|
1952
|
+
$('rp-modal-body').innerHTML = '';
|
|
1953
|
+
}
|
|
1954
|
+
// wire the close handlers (once)
|
|
1955
|
+
(function(){
|
|
1956
|
+
var closeBtn = $('rp-modal-close');
|
|
1957
|
+
if(closeBtn) closeBtn.addEventListener('click', closeModal);
|
|
1958
|
+
var back = $('rp-modal-back');
|
|
1959
|
+
if(back) back.addEventListener('click', function(e){ if(e.target===back) closeModal(); });
|
|
1960
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeModal(); });
|
|
1961
|
+
})();
|
|
1962
|
+
|
|
1963
|
+
// ── TTS voice picker modal ──
|
|
1964
|
+
// A robust, searchable, debounced-fetched picker for any TTS provider that
|
|
1965
|
+
// exposes /api/voices/<provider> on the scheduler. Designed specifically
|
|
1966
|
+
// for ElevenLabs (hundreds of voices, needs search + loading + error
|
|
1967
|
+
// states) but works for any provider — Kokoro, Piper, etc.
|
|
1968
|
+
//
|
|
1969
|
+
// Usage: openTtsVoicePicker('elevenlabs', function(voiceId){ ... });
|
|
1970
|
+
// The callback receives the picked voice_id and the modal closes itself.
|
|
1971
|
+
// If the user dismisses the modal (Esc, backdrop, ✕) the callback is NOT
|
|
1972
|
+
// called. The catalog is cached in ttsVoiceCache[provider] and re-used
|
|
1973
|
+
// on subsequent opens; a manual "Refresh" button forces a refetch.
|
|
1974
|
+
var ttsPickerState = {
|
|
1975
|
+
provider: null,
|
|
1976
|
+
voices: [],
|
|
1977
|
+
filter: '',
|
|
1978
|
+
loading: false,
|
|
1979
|
+
error: null,
|
|
1980
|
+
onPick: null,
|
|
1981
|
+
};
|
|
1982
|
+
function openTtsVoicePicker(provider, onPick){
|
|
1983
|
+
ttsPickerState.provider = provider || 'elevenlabs';
|
|
1984
|
+
ttsPickerState.onPick = (typeof onPick === 'function') ? onPick : null;
|
|
1985
|
+
ttsPickerState.filter = '';
|
|
1986
|
+
showModal('Browse '+ttsPickerState.provider+' voices', buildTtsPickerBody());
|
|
1987
|
+
// Focus the search box on the next paint so the modal animation has
|
|
1988
|
+
// started and the input is visible.
|
|
1989
|
+
setTimeout(function(){
|
|
1990
|
+
var inp = $('rp-voice-picker-search');
|
|
1991
|
+
if(inp){ try{ inp.focus(); inp.select && inp.select(); }catch(e){} }
|
|
1992
|
+
}, 30);
|
|
1993
|
+
// Re-render after the catalog is loaded (may already be cached).
|
|
1994
|
+
renderTtsPicker();
|
|
1995
|
+
loadTtsVoiceCatalog(ttsPickerState.provider, {force:false}).then(function(){
|
|
1996
|
+
// The modal may have been closed while the fetch was in flight.
|
|
1997
|
+
if(ttsPickerState.provider !== provider) return;
|
|
1998
|
+
renderTtsPicker();
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
function buildTtsPickerBody(){
|
|
2002
|
+
// Toolbar (search + refresh) + scrollable results area. The two
|
|
2003
|
+
// containers (#rp-voice-picker-results + #rp-voice-picker-status) are
|
|
2004
|
+
// re-rendered in place by renderTtsPicker(), so opening the modal
|
|
2005
|
+
// repeatedly is cheap and we don't leak listeners.
|
|
2006
|
+
return ''
|
|
2007
|
+
+ '<div class="rp-voice-picker">'
|
|
2008
|
+
+ '<div class="rp-voice-picker-toolbar">'
|
|
2009
|
+
+ '<input type="search" id="rp-voice-picker-search" placeholder="Search by name, id, language, or category…" autocomplete="off" spellcheck="false">'
|
|
2010
|
+
+ '<button type="button" class="rp-btn" id="rp-voice-picker-refresh" title="Refetch the voice catalog from the scheduler">↻ Refresh</button>'
|
|
2011
|
+
+ '</div>'
|
|
2012
|
+
+ '<div class="rp-voice-picker-status" id="rp-voice-picker-status"></div>'
|
|
2013
|
+
+ '<div class="rp-voice-picker-results" id="rp-voice-picker-results"></div>'
|
|
2014
|
+
+ '<div class="rp-voice-picker-foot">'
|
|
2015
|
+
+ '<span class="rp-muted">Click a voice to select it · Esc to cancel</span>'
|
|
2016
|
+
+ '</div>'
|
|
2017
|
+
+ '</div>';
|
|
2018
|
+
}
|
|
2019
|
+
function renderTtsPicker(){
|
|
2020
|
+
var status = $('rp-voice-picker-status');
|
|
2021
|
+
var results = $('rp-voice-picker-results');
|
|
2022
|
+
if(!status || !results) return; // modal was closed
|
|
2023
|
+
var cache = ttsVoiceCache[ttsPickerState.provider];
|
|
2024
|
+
var voices = (cache && Array.isArray(cache.voices)) ? cache.voices : [];
|
|
2025
|
+
var warning = cache && cache.warning;
|
|
2026
|
+
var source = cache && cache.source;
|
|
2027
|
+
|
|
2028
|
+
// ── status row (loading / error / counts / warning) ──
|
|
2029
|
+
if(ttsPickerState.loading && !cache){
|
|
2030
|
+
status.className = 'rp-voice-picker-status busy';
|
|
2031
|
+
status.innerHTML = '<span class="rp-spinner"></span> Loading '+esc(ttsPickerState.provider)+' voice catalog…';
|
|
2032
|
+
results.innerHTML = '';
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if(!cache){
|
|
2036
|
+
status.className = 'rp-voice-picker-status bad';
|
|
2037
|
+
status.innerHTML = '⚠ No catalog data for '+esc(ttsPickerState.provider)+'. Click Refresh to retry.';
|
|
2038
|
+
results.innerHTML = '';
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if(warning && !voices.length){
|
|
2042
|
+
status.className = 'rp-voice-picker-status bad';
|
|
2043
|
+
status.innerHTML = '⚠ '+esc(warning);
|
|
2044
|
+
results.innerHTML = '';
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
var bits = [];
|
|
2048
|
+
bits.push('<b>'+voices.length+'</b> '+esc(ttsPickerState.provider)+' voice'+(voices.length===1?'':'s'));
|
|
2049
|
+
if(source) bits.push('· source: <span class="rp-mono">'+esc(source)+'</span>');
|
|
2050
|
+
if(warning) bits.push('<span style="color:var(--bad)">⚠ '+esc(warning)+'</span>');
|
|
2051
|
+
status.className = warning ? 'rp-voice-picker-status warn' : 'rp-voice-picker-status';
|
|
2052
|
+
status.innerHTML = bits.join(' ');
|
|
2053
|
+
|
|
2054
|
+
// ── filtered results ──
|
|
2055
|
+
var q = String(ttsPickerState.filter || '').toLowerCase().trim();
|
|
2056
|
+
var filtered = voices;
|
|
2057
|
+
if(q){
|
|
2058
|
+
filtered = voices.filter(function(v){
|
|
2059
|
+
var blob = [
|
|
2060
|
+
v.id, v.name, v.language, v.gender, v.category, v.description
|
|
2061
|
+
].map(function(x){return x==null?'':String(x);}).join(' ').toLowerCase();
|
|
2062
|
+
return blob.indexOf(q) >= 0;
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
if(!filtered.length){
|
|
2066
|
+
results.innerHTML = '<div class="rp-voice-picker-empty">'
|
|
2067
|
+
+ (q ? ('No voices match “'+esc(q)+'”.')
|
|
2068
|
+
: ('No voices available for this provider.'))
|
|
2069
|
+
+ '</div>';
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
// Group by category for a tidy scannable list. Providers that don't
|
|
2073
|
+
// set a category (Kokoro static list, etc.) all land in "Other".
|
|
2074
|
+
var groups = {};
|
|
2075
|
+
for(var i=0;i<filtered.length;i++){
|
|
2076
|
+
var v = filtered[i];
|
|
2077
|
+
var key = v.category || (v.language ? ('Language: '+v.language) : 'Other');
|
|
2078
|
+
if(!groups[key]) groups[key] = [];
|
|
2079
|
+
groups[key].push(v);
|
|
2080
|
+
}
|
|
2081
|
+
var groupNames = Object.keys(groups).sort(function(a,b){
|
|
2082
|
+
if(a==='Other') return 1;
|
|
2083
|
+
if(b==='Other') return -1;
|
|
2084
|
+
return a.localeCompare(b);
|
|
2085
|
+
});
|
|
2086
|
+
var html = '';
|
|
2087
|
+
for(var g=0;g<groupNames.length;g++){
|
|
2088
|
+
var name = groupNames[g];
|
|
2089
|
+
html += '<div class="rp-vp-group"><div class="rp-vp-group-head">'+esc(name)+' · '+groups[name].length+'</div>';
|
|
2090
|
+
for(var j=0;j<groups[name].length;j++){
|
|
2091
|
+
var voice = groups[name][j];
|
|
2092
|
+
var id = voice.id || voice.voice_id || '';
|
|
2093
|
+
if(!id) continue;
|
|
2094
|
+
var meta = [];
|
|
2095
|
+
if(voice.language) meta.push(voice.language);
|
|
2096
|
+
if(voice.gender) meta.push(voice.gender);
|
|
2097
|
+
var sub = meta.length ? ('<span class="rp-vp-meta">'+esc(meta.join(' · '))+'</span>') : '';
|
|
2098
|
+
var desc = voice.description ? ('<div class="rp-vp-desc">'+esc(voice.description)+'</div>') : '';
|
|
2099
|
+
html += '<button type="button" class="rp-vp-item" data-voice-id="'+esc(id)+'">'
|
|
2100
|
+
+ '<div class="rp-vp-row1"><span class="rp-vp-name">'+esc(voice.name||id)+'</span>'+sub+'</div>'
|
|
2101
|
+
+ '<div class="rp-vp-id">'+esc(id)+'</div>'
|
|
2102
|
+
+ desc
|
|
2103
|
+
+ '</button>';
|
|
2104
|
+
}
|
|
2105
|
+
html += '</div>';
|
|
2106
|
+
}
|
|
2107
|
+
results.innerHTML = html;
|
|
2108
|
+
}
|
|
2109
|
+
// Click delegation for picker items + the refresh button + the search
|
|
2110
|
+
// input. Bound once on the document so re-opening the modal doesn't
|
|
2111
|
+
// stack listeners.
|
|
2112
|
+
document.addEventListener('click', function(e){
|
|
2113
|
+
var btn = e.target.closest && e.target.closest('.rp-vp-item');
|
|
2114
|
+
if(btn){
|
|
2115
|
+
var vid = btn.getAttribute('data-voice-id');
|
|
2116
|
+
if(vid && ttsPickerState.onPick){ ttsPickerState.onPick(vid); }
|
|
2117
|
+
closeModal();
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
var r = e.target.closest && e.target.closest('#rp-voice-picker-refresh');
|
|
2121
|
+
if(r){
|
|
2122
|
+
// Re-fetch the catalog, ignore cache, then re-render.
|
|
2123
|
+
loadTtsVoiceCatalog(ttsPickerState.provider, {force:true}).then(function(){ renderTtsPicker(); });
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
});
|
|
2127
|
+
document.addEventListener('input', function(e){
|
|
2128
|
+
if(e.target && e.target.id === 'rp-voice-picker-search'){
|
|
2129
|
+
ttsPickerState.filter = e.target.value;
|
|
2130
|
+
renderTtsPicker();
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
// ── Live Sessions sub-tab ──
|
|
2135
|
+
var rpLiveAuto = true;
|
|
2136
|
+
var rpLiveClockTimer = null;
|
|
2137
|
+
function startLiveAutoRefresh(){
|
|
2138
|
+
if(rpIntervals.live){ clearInterval(rpIntervals.live); rpIntervals.live=null; }
|
|
2139
|
+
if(rpLiveClockTimer){ clearInterval(rpLiveClockTimer); rpLiveClockTimer=null; }
|
|
2140
|
+
if(rpLiveAuto){
|
|
2141
|
+
rpIntervals.live = setInterval(pollLiveSessions, 3000);
|
|
2142
|
+
rpLiveClockTimer = setInterval(updateLiveDurations, 1000);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
function updateLiveDurations(){
|
|
2146
|
+
var tbody = $('rp-live-tbody'); if(!tbody) return;
|
|
2147
|
+
var cells = tbody.querySelectorAll('td[data-started]');
|
|
2148
|
+
for(var i=0;i<cells.length;i++){
|
|
2149
|
+
var t = parseInt(cells[i].getAttribute('data-started'),10);
|
|
2150
|
+
if(!t) continue;
|
|
2151
|
+
cells[i].textContent = rpFmtDuration(Math.floor((Date.now()-t)/1000));
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
function pollLiveSessions(){
|
|
2155
|
+
getJson('/robopark/api/sessions?active_only=true').then(function(rows){
|
|
2156
|
+
renderLiveSessions(Array.isArray(rows)?rows:[]);
|
|
2157
|
+
}).catch(function(e){ /* soft fail */ });
|
|
2158
|
+
}
|
|
2159
|
+
function renderLiveSessions(rows){
|
|
2160
|
+
var tbody = $('rp-live-tbody'), empty = $('rp-live-empty'), wrap = $('rp-live-wrap');
|
|
2161
|
+
var searchEl=$('rp-live-search'), robotEl=$('rp-live-robot');
|
|
2162
|
+
var search = searchEl ? String(searchEl.value||'').toLowerCase() : '';
|
|
2163
|
+
var robotF = robotEl ? String(robotEl.value||'') : '';
|
|
2164
|
+
// refresh the robot dropdown whenever the set changes
|
|
2165
|
+
if(robotEl){
|
|
2166
|
+
var known={}; for(var i=0;i<rows.length;i++){ var rid=String(rows[i].robot_id||''); if(rid) known[rid]=1; }
|
|
2167
|
+
var present = robotEl.querySelectorAll('option');
|
|
2168
|
+
var have={};
|
|
2169
|
+
for(var j=1;j<present.length;j++){ have[present[j].value]=1; if(!known[present[j].value]){ robotEl.removeChild(present[j]); } }
|
|
2170
|
+
for(var rid in known){ if(!have[rid]){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; robotEl.appendChild(o); } }
|
|
2171
|
+
}
|
|
2172
|
+
var filtered = rows.filter(function(s){
|
|
2173
|
+
if(robotF && String(s.robot_id||'')!==robotF) return false;
|
|
2174
|
+
if(search){
|
|
2175
|
+
var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();
|
|
2176
|
+
if(blob.indexOf(search)<0) return false;
|
|
2177
|
+
}
|
|
2178
|
+
return true;
|
|
2179
|
+
});
|
|
2180
|
+
if(!filtered.length){ tbody.innerHTML=''; wrap.style.display='none'; empty.style.display='block'; }
|
|
2181
|
+
else { empty.style.display='none'; wrap.style.display='block'; }
|
|
2182
|
+
var html='';
|
|
2183
|
+
for(var i=0;i<filtered.length;i++){
|
|
2184
|
+
var s = filtered[i];
|
|
2185
|
+
var rid = s.robot_id || '';
|
|
2186
|
+
var sid = s.session_id || '';
|
|
2187
|
+
var started = s.started_at ? Date.parse(s.started_at) : null;
|
|
2188
|
+
var elapsed = started ? Math.floor((Date.now()-started)/1000) : null;
|
|
2189
|
+
html += '<tr>'
|
|
2190
|
+
+ '<td><b>'+esc(rid)+'</b></td>'
|
|
2191
|
+
+ '<td><span class="sid" title="'+esc(sid)+'">'+esc(sid)+'</span></td>'
|
|
2192
|
+
+ '<td><span class="t-rel"><span class="live-dot"></span>'+esc(rpFmtRelative(started))+'</span><span class="t-abs">'+esc(rpFmtAbs(started))+'</span></td>'
|
|
2193
|
+
+ '<td data-started="'+started+'">'+esc(rpFmtDuration(elapsed))+'</td>'
|
|
2194
|
+
+ '<td><span class="rp-end-badge active">active</span></td>'
|
|
2195
|
+
+ '<td style="display:flex;gap:0.3rem;">'
|
|
2196
|
+
+ '<button class="rp-btn" data-act="open-robot" data-robot="'+esc(rid)+'">Open robot</button>'
|
|
2197
|
+
+ '<button class="rp-btn danger" data-act="end-session" data-sid="'+esc(sid)+'" data-robot="'+esc(rid)+'">End</button>'
|
|
2198
|
+
+ '</td>'
|
|
2199
|
+
+ '</tr>';
|
|
2200
|
+
}
|
|
2201
|
+
tbody.innerHTML = html;
|
|
2202
|
+
// update the tab badge
|
|
2203
|
+
var badge = $('rps-live-badge'); if(badge) badge.textContent = filtered.length;
|
|
2204
|
+
}
|
|
2205
|
+
function endSessionFromTable(sessionId, robotId){
|
|
2206
|
+
if(!confirm('End session '+sessionId+' for '+robotId+'?')) return;
|
|
2207
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/end-session?reason=operator').then(function(){
|
|
2208
|
+
showOut('ended session for '+robotId);
|
|
2209
|
+
pollLiveSessions();
|
|
2210
|
+
pollRobopark();
|
|
2211
|
+
pollRuntimeHealth();
|
|
2212
|
+
}).catch(function(e){ showOut('end-session: '+e.message); });
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// wire live tab interactions
|
|
2216
|
+
(function(){
|
|
2217
|
+
var s=$('rp-live-search'); if(s) s.addEventListener('input', function(){ pollLiveSessions(); });
|
|
2218
|
+
var r=$('rp-live-robot'); if(r) r.addEventListener('change', function(){ pollLiveSessions(); });
|
|
2219
|
+
var a=$('rp-live-autoref'); if(a) a.addEventListener('change', function(){ rpLiveAuto = !!a.checked; startLiveAutoRefresh(); });
|
|
2220
|
+
var rb=$('rp-live-refresh'); if(rb) rb.addEventListener('click', pollLiveSessions);
|
|
2221
|
+
var tb=$('rp-live-tbody'); if(tb) tb.addEventListener('click', function(e){
|
|
2222
|
+
var b = e.target.closest ? e.target.closest('.rp-btn') : null;
|
|
2223
|
+
if(!b) return;
|
|
2224
|
+
var act = b.getAttribute('data-act');
|
|
2225
|
+
if(act==='end-session'){ endSessionFromTable(b.getAttribute('data-sid'), b.getAttribute('data-robot')); }
|
|
2226
|
+
if(act==='open-robot'){ var rid=b.getAttribute('data-robot'); if(rid){ setRpSubTab('fleet'); } }
|
|
2227
|
+
});
|
|
2228
|
+
})();
|
|
2229
|
+
|
|
2230
|
+
// ── History sub-tab ──
|
|
2231
|
+
var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };
|
|
2232
|
+
var rpHistoryPage = 0;
|
|
2233
|
+
var RP_HISTORY_PAGE_SIZE = 25;
|
|
2234
|
+
function pollHistory(){
|
|
2235
|
+
getJson('/robopark/api/sessions?active_only=false&limit=500').then(function(rows){
|
|
2236
|
+
rpHistoryAll = Array.isArray(rows)?rows:[];
|
|
2237
|
+
renderHistory(rpHistoryAll);
|
|
2238
|
+
}).catch(function(e){
|
|
2239
|
+
var tbody=$('rp-hist-tbody'); if(tbody) tbody.innerHTML='';
|
|
2240
|
+
var empty=$('rp-hist-empty'); if(empty){ empty.style.display='block'; empty.textContent='history unreachable: '+(e&&e.message||''); }
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
function rpHistoryWithinRange(s, range){
|
|
2244
|
+
if(range==='all'||!range) return true;
|
|
2245
|
+
if(!s.started_at) return true; // include rows with no timestamp when no filter set
|
|
2246
|
+
var t = Date.parse(s.started_at); if(isNaN(t)) return true;
|
|
2247
|
+
var now = Date.now();
|
|
2248
|
+
if(range==='24h') return (now-t) <= 86400e3;
|
|
2249
|
+
if(range==='7d') return (now-t) <= 7*86400e3;
|
|
2250
|
+
if(range==='30d') return (now-t) <= 30*86400e3;
|
|
2251
|
+
return true;
|
|
2252
|
+
}
|
|
2253
|
+
function renderHistory(rows){
|
|
2254
|
+
rows = rows || rpHistoryAll;
|
|
2255
|
+
var tbody = $('rp-hist-tbody'), empty = $('rp-hist-empty'), wrap = $('rp-hist-wrap');
|
|
2256
|
+
var info = $('rp-hist-info');
|
|
2257
|
+
// refresh robot dropdown from rows
|
|
2258
|
+
var rsel = $('rp-hist-robot');
|
|
2259
|
+
if(rsel){
|
|
2260
|
+
var known={};
|
|
2261
|
+
for(var i=0;i<rows.length;i++){ var r=String(rows[i].robot_id||''); if(r) known[r]=1; }
|
|
2262
|
+
// remove stale (preserve first "all" option)
|
|
2263
|
+
var opts=rsel.querySelectorAll('option');
|
|
2264
|
+
for(var j=opts.length-1;j>=1;j--){ if(!known[opts[j].value]) rsel.removeChild(opts[j]); }
|
|
2265
|
+
for(var rid in known){
|
|
2266
|
+
var present=false; for(var k=1;k<opts.length;k++){ if(opts[k].value===rid){ present=true; break; } }
|
|
2267
|
+
if(!present){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; rsel.appendChild(o); }
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
var f = rpHistoryFilter;
|
|
2271
|
+
var filtered = rows.filter(function(s){
|
|
2272
|
+
if(f.robot && String(s.robot_id||'')!==f.robot) return false;
|
|
2273
|
+
if(f.endReason && String(s.end_reason||'').toLowerCase()!==f.endReason) return false;
|
|
2274
|
+
if(f.search){
|
|
2275
|
+
var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();
|
|
2276
|
+
if(blob.indexOf(f.search)<0) return false;
|
|
2277
|
+
}
|
|
2278
|
+
if(!rpHistoryWithinRange(s, f.range)) return false;
|
|
2279
|
+
return true;
|
|
2280
|
+
});
|
|
2281
|
+
var totalPages = Math.max(1, Math.ceil(filtered.length / RP_HISTORY_PAGE_SIZE));
|
|
2282
|
+
if(rpHistoryPage >= totalPages) rpHistoryPage = totalPages-1;
|
|
2283
|
+
if(rpHistoryPage < 0) rpHistoryPage = 0;
|
|
2284
|
+
var start = rpHistoryPage * RP_HISTORY_PAGE_SIZE;
|
|
2285
|
+
var slice = filtered.slice(start, start+RP_HISTORY_PAGE_SIZE);
|
|
2286
|
+
if(!slice.length){
|
|
2287
|
+
tbody.innerHTML = '';
|
|
2288
|
+
wrap.style.display='none';
|
|
2289
|
+
empty.style.display='block';
|
|
2290
|
+
empty.textContent = filtered.length ? 'no results on this page' : 'no sessions match the current filters';
|
|
2291
|
+
} else {
|
|
2292
|
+
empty.style.display='none';
|
|
2293
|
+
wrap.style.display='block';
|
|
2294
|
+
}
|
|
2295
|
+
var html='';
|
|
2296
|
+
for(var i=0;i<slice.length;i++){
|
|
2297
|
+
var s = slice[i];
|
|
2298
|
+
var sid = s.session_id || '';
|
|
2299
|
+
var rid = s.robot_id || '';
|
|
2300
|
+
var started = s.started_at ? Date.parse(s.started_at) : null;
|
|
2301
|
+
var ended = s.ended_at ? Date.parse(s.ended_at) : null;
|
|
2302
|
+
var dur = s.duration_seconds!=null ? s.duration_seconds : (started&&ended?Math.floor((ended-started)/1000):null);
|
|
2303
|
+
var reason = s.end_reason || '';
|
|
2304
|
+
var reasonCls = rpEndReasonClass(reason);
|
|
2305
|
+
var trig = s.trigger_count!=null ? s.trigger_count : '—';
|
|
2306
|
+
var lat = s.avg_latency_ms!=null ? Math.round(s.avg_latency_ms)+'ms' : '—';
|
|
2307
|
+
html += '<tr data-sid="'+esc(sid)+'">'
|
|
2308
|
+
+ '<td><span class="sid" title="'+esc(sid)+'">'+esc(sid)+'</span></td>'
|
|
2309
|
+
+ '<td><b>'+esc(rid)+'</b></td>'
|
|
2310
|
+
+ '<td><span class="t-rel">'+esc(rpFmtRelative(started))+'</span><span class="t-abs">'+esc(rpFmtAbs(started))+'</span></td>'
|
|
2311
|
+
+ '<td><span class="t-rel">'+esc(rpFmtRelative(ended))+'</span><span class="t-abs">'+esc(rpFmtAbs(ended))+'</span></td>'
|
|
2312
|
+
+ '<td>'+esc(rpFmtDuration(dur))+'</td>'
|
|
2313
|
+
+ '<td><span class="rp-end-badge '+(reasonCls||'')+'">'+esc(reason||'—')+'</span></td>'
|
|
2314
|
+
+ '<td>'+esc(trig)+'</td>'
|
|
2315
|
+
+ '<td>'+esc(lat)+'</td>'
|
|
2316
|
+
+ '<td><button class="rp-btn" data-act="view" data-sid="'+esc(sid)+'">View</button></td>'
|
|
2317
|
+
+ '</tr>';
|
|
2318
|
+
}
|
|
2319
|
+
tbody.innerHTML = html;
|
|
2320
|
+
if(info) info.textContent = 'showing '+(slice.length?(start+1):0)+'–'+(start+slice.length)+' of '+filtered.length+' · page '+(rpHistoryPage+1)+' / '+totalPages;
|
|
2321
|
+
var prev=$('rp-hist-prev'), next=$('rp-hist-next');
|
|
2322
|
+
if(prev) prev.disabled = rpHistoryPage<=0;
|
|
2323
|
+
if(next) next.disabled = rpHistoryPage>=totalPages-1;
|
|
2324
|
+
}
|
|
2325
|
+
function openSessionDetailModal(s){
|
|
2326
|
+
if(!s) return;
|
|
2327
|
+
function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
|
|
2328
|
+
var html = '';
|
|
2329
|
+
html += row('Session ID', s.session_id);
|
|
2330
|
+
html += row('Robot', s.robot_id);
|
|
2331
|
+
html += row('Room', s.room_name);
|
|
2332
|
+
html += row('Started', rpFmtAbs(s.started_at) + ' ('+rpFmtRelative(s.started_at)+')');
|
|
2333
|
+
html += row('Ended', rpFmtAbs(s.ended_at) + ' ('+rpFmtRelative(s.ended_at)+')');
|
|
2334
|
+
html += row('Duration', rpFmtDuration(s.duration_seconds));
|
|
2335
|
+
html += row('End reason', s.end_reason || '—');
|
|
2336
|
+
html += row('Trigger count', s.trigger_count!=null?s.trigger_count:'—');
|
|
2337
|
+
html += row('Avg latency', s.avg_latency_ms!=null?Math.round(s.avg_latency_ms)+'ms':'—');
|
|
2338
|
+
html += row('Drop rate', s.drop_rate!=null?Math.round(s.drop_rate*100)+'%':'—');
|
|
2339
|
+
html += row('Operator', s.operator_id || s.ended_by || '—');
|
|
2340
|
+
html += row('Character', s.character_id || s.character_name || '—');
|
|
2341
|
+
html += row('Voice stack', s.voice_stack_id || '—');
|
|
2342
|
+
if(s.notes) html += row('Notes', s.notes);
|
|
2343
|
+
if(s.metadata){
|
|
2344
|
+
html += '<div class="kv"><div class="k">Metadata</div><div class="v"><pre>'+esc(typeof s.metadata==='string'?s.metadata:JSON.stringify(s.metadata,null,2))+'</pre></div></div>';
|
|
2345
|
+
}
|
|
2346
|
+
showModal('Session '+(s.session_id||''), html);
|
|
2347
|
+
}
|
|
2348
|
+
(function(){
|
|
2349
|
+
var s=$('rp-hist-search'); if(s) s.addEventListener('input', function(){ rpHistoryFilter.search = String(s.value||'').toLowerCase(); rpHistoryPage=0; renderHistory(); });
|
|
2350
|
+
var r=$('rp-hist-robot'); if(r) r.addEventListener('change', function(){ rpHistoryFilter.robot = r.value||''; rpHistoryPage=0; renderHistory(); });
|
|
2351
|
+
var re=$('rp-hist-reason'); if(re) re.addEventListener('change', function(){ rpHistoryFilter.endReason = re.value||''; rpHistoryPage=0; renderHistory(); });
|
|
2352
|
+
var ra=$('rp-hist-range'); if(ra) ra.addEventListener('change', function(){ rpHistoryFilter.range = ra.value||'7d'; rpHistoryPage=0; renderHistory(); });
|
|
2353
|
+
var rb=$('rp-hist-refresh'); if(rb) rb.addEventListener('click', pollHistory);
|
|
2354
|
+
var prev=$('rp-hist-prev'); if(prev) prev.addEventListener('click', function(){ rpHistoryPage=Math.max(0,rpHistoryPage-1); renderHistory(); });
|
|
2355
|
+
var next=$('rp-hist-next'); if(next) next.addEventListener('click', function(){ rpHistoryPage=rpHistoryPage+1; renderHistory(); });
|
|
2356
|
+
var tb=$('rp-hist-tbody'); if(tb) tb.addEventListener('click', function(e){
|
|
2357
|
+
var b = e.target.closest ? e.target.closest('.rp-btn') : null; if(!b) return;
|
|
2358
|
+
if(b.getAttribute('data-act')==='view'){
|
|
2359
|
+
var sid = b.getAttribute('data-sid');
|
|
2360
|
+
for(var i=0;i<rpHistoryAll.length;i++){ if(String(rpHistoryAll[i].session_id||'')===sid){ openSessionDetailModal(rpHistoryAll[i]); return; } }
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
})();
|
|
2364
|
+
|
|
2365
|
+
// ── Audit Log sub-tab ──
|
|
2366
|
+
var rpAuditFilter = { category: '' };
|
|
2367
|
+
var rpAuditLast = [];
|
|
2368
|
+
function pollAuditLog(){
|
|
2369
|
+
var qs = rpAuditFilter.category ? ('?category='+encodeURIComponent(rpAuditFilter.category)+'&limit=200') : '?limit=200';
|
|
2370
|
+
getJson('/robopark/api/history'+qs).then(function(rows){
|
|
2371
|
+
renderAuditLog(Array.isArray(rows)?rows:[]);
|
|
2372
|
+
}).catch(function(){
|
|
2373
|
+
var tbody=$('rp-audit-tbody'); if(tbody) tbody.innerHTML='';
|
|
2374
|
+
var empty=$('rp-audit-empty'); if(empty){ empty.style.display='block'; empty.textContent='audit log unreachable'; }
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
function renderAuditLog(rows){
|
|
2378
|
+
var tbody=$('rp-audit-tbody'), empty=$('rp-audit-empty'), wrap=$('rp-audit-wrap');
|
|
2379
|
+
if(!rows.length){
|
|
2380
|
+
tbody.innerHTML='';
|
|
2381
|
+
wrap.style.display='none';
|
|
2382
|
+
empty.style.display='block';
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
empty.style.display='none';
|
|
2386
|
+
wrap.style.display='block';
|
|
2387
|
+
var html='';
|
|
2388
|
+
for(var i=0;i<rows.length;i++){
|
|
2389
|
+
var ev=rows[i];
|
|
2390
|
+
var ts = ev.timestamp;
|
|
2391
|
+
var cat = ev.category || '—';
|
|
2392
|
+
var act = ev.action || '—';
|
|
2393
|
+
var ent = (ev.entity_type||'')+(ev.entity_id?(' · '+ev.entity_id):'');
|
|
2394
|
+
var det = ev.details || '';
|
|
2395
|
+
var src = ev.actor || '—';
|
|
2396
|
+
if(typeof det==='object') det = JSON.stringify(det);
|
|
2397
|
+
var detShort = String(det);
|
|
2398
|
+
if(detShort.length>80) detShort = detShort.slice(0,77)+'…';
|
|
2399
|
+
html += '<tr data-idx="'+i+'">'
|
|
2400
|
+
+ '<td><span class="t-rel">'+esc(rpFmtRelative(ts))+'</span><span class="t-abs">'+esc(rpFmtAbs(ts))+'</span></td>'
|
|
2401
|
+
+ '<td><span class="rp-end-badge">'+esc(rpCategoryIcon(cat))+' '+esc(cat)+'</span></td>'
|
|
2402
|
+
+ '<td><b>'+esc(act)+'</b></td>'
|
|
2403
|
+
+ '<td>'+esc(ent||'—')+'</td>'
|
|
2404
|
+
+ '<td style="max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="'+esc(String(det).replace(/"/g,'"'))+'">'+esc(detShort)+'</td>'
|
|
2405
|
+
+ '<td>'+esc(src)+'</td>'
|
|
2406
|
+
+ '</tr>';
|
|
2407
|
+
}
|
|
2408
|
+
tbody.innerHTML = html;
|
|
2409
|
+
// stash for modal
|
|
2410
|
+
rpAuditLast = rows;
|
|
2411
|
+
}
|
|
2412
|
+
function openAuditDetailModal(ev){
|
|
2413
|
+
if(!ev) return;
|
|
2414
|
+
function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
|
|
2415
|
+
var html = '';
|
|
2416
|
+
html += row('Time', rpFmtAbs(ev.timestamp) + ' ('+rpFmtRelative(ev.timestamp)+')');
|
|
2417
|
+
html += row('Category', rpCategoryIcon(ev.category)+' '+(ev.category||'—'));
|
|
2418
|
+
html += row('Action', ev.action);
|
|
2419
|
+
html += row('Entity type', ev.entity_type);
|
|
2420
|
+
html += row('Entity ID', ev.entity_id);
|
|
2421
|
+
html += row('Actor / source', ev.actor);
|
|
2422
|
+
if(ev.details){
|
|
2423
|
+
var dStr = typeof ev.details==='string' ? ev.details : JSON.stringify(ev.details,null,2);
|
|
2424
|
+
html += '<div class="kv"><div class="k">Details</div><div class="v"><pre>'+esc(dStr)+'</pre></div></div>';
|
|
2425
|
+
}
|
|
2426
|
+
showModal('Audit · '+(ev.action||'event')+' · '+(ev.entity_id||ev.entity_type||''), html);
|
|
2427
|
+
}
|
|
2428
|
+
(function(){
|
|
2429
|
+
var chips = document.querySelectorAll('.rp-cat-chip');
|
|
2430
|
+
for(var i=0;i<chips.length;i++){
|
|
2431
|
+
(function(c){
|
|
2432
|
+
c.addEventListener('click', function(){
|
|
2433
|
+
for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');
|
|
2434
|
+
c.classList.add('active');
|
|
2435
|
+
rpAuditFilter.category = c.getAttribute('data-cat')||'';
|
|
2436
|
+
pollAuditLog();
|
|
2437
|
+
});
|
|
2438
|
+
})(chips[i]);
|
|
2439
|
+
}
|
|
2440
|
+
var rb=$('rp-audit-refresh'); if(rb) rb.addEventListener('click', pollAuditLog);
|
|
2441
|
+
var tb=$('rp-audit-tbody'); if(tb) tb.addEventListener('click', function(e){
|
|
2442
|
+
var tr = e.target.closest ? e.target.closest('tr') : null; if(!tr) return;
|
|
2443
|
+
var idx = parseInt(tr.getAttribute('data-idx'),10);
|
|
2444
|
+
if(!isNaN(idx) && rpAuditLast[idx]) openAuditDetailModal(rpAuditLast[idx]);
|
|
2445
|
+
});
|
|
2446
|
+
})();
|
|
2447
|
+
|
|
2448
|
+
// ── Fleet sub-tab (uses the existing #rp-grid) ──
|
|
2449
|
+
// The Fleet sub-tab reuses the existing #rp-grid render in pollRobopark().
|
|
2450
|
+
// We add sparklines by overlaying on top of the existing cards.
|
|
2451
|
+
function pollFleet(){
|
|
2452
|
+
pollRobopark();
|
|
2453
|
+
}
|
|
2454
|
+
function drawRobotSparkline(canvas, sessions){
|
|
2455
|
+
if(!canvas || !canvas.getContext) return;
|
|
2456
|
+
var ctx = canvas.getContext('2d');
|
|
2457
|
+
var w = canvas.width = canvas.clientWidth;
|
|
2458
|
+
var h = canvas.height = canvas.clientHeight;
|
|
2459
|
+
ctx.clearRect(0,0,w,h);
|
|
2460
|
+
if(!sessions || sessions.length<2){
|
|
2461
|
+
ctx.fillStyle = 'rgba(158,147,180,0.4)';
|
|
2462
|
+
ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
|
|
2463
|
+
ctx.fillText('no history', 6, h/2+3);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
var counts = sessions.map(function(s){
|
|
2467
|
+
return (s.duration_seconds!=null?s.duration_seconds:(s.ended_at&&s.started_at?(Date.parse(s.ended_at)-Date.parse(s.started_at))/1000:0));
|
|
2468
|
+
});
|
|
2469
|
+
var mx = Math.max.apply(null, counts); if(mx<=0) mx=1;
|
|
2470
|
+
var pad = 2;
|
|
2471
|
+
var stepX = (w - pad*2) / (counts.length - 1);
|
|
2472
|
+
ctx.beginPath();
|
|
2473
|
+
for(var i=0;i<counts.length;i++){
|
|
2474
|
+
var x = pad + i*stepX;
|
|
2475
|
+
var y = h - pad - (counts[i]/mx) * (h - pad*2);
|
|
2476
|
+
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
|
2477
|
+
}
|
|
2478
|
+
// gold gradient line
|
|
2479
|
+
try{
|
|
2480
|
+
var grad = ctx.createLinearGradient(0,0,0,h);
|
|
2481
|
+
grad.addColorStop(0, '#FFD86B');
|
|
2482
|
+
grad.addColorStop(1, '#E6B23C');
|
|
2483
|
+
ctx.strokeStyle = grad;
|
|
2484
|
+
}catch(e){ ctx.strokeStyle = '#E6B23C'; }
|
|
2485
|
+
ctx.lineWidth = 1.4;
|
|
2486
|
+
ctx.lineJoin = 'round';
|
|
2487
|
+
ctx.stroke();
|
|
2488
|
+
// fill underneath
|
|
2489
|
+
ctx.lineTo(pad+(counts.length-1)*stepX, h-pad);
|
|
2490
|
+
ctx.lineTo(pad, h-pad);
|
|
2491
|
+
ctx.closePath();
|
|
2492
|
+
ctx.fillStyle = 'rgba(230,178,60,0.18)';
|
|
2493
|
+
ctx.fill();
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ── Overview sub-tab ──
|
|
2497
|
+
function pollOverview(){
|
|
2498
|
+
Promise.all([
|
|
2499
|
+
getJson('/robopark/api/sessions?active_only=false&limit=200').catch(function(){ return []; }),
|
|
2500
|
+
getJson('/robopark/api/sessions/stats').catch(function(){ return {}; }),
|
|
2501
|
+
getJson('/robopark/api/telemetry').catch(function(){ return {robots:[],totals:{}}; }),
|
|
2502
|
+
getJson('/robopark/api/history?limit=20').catch(function(){ return []; })
|
|
2503
|
+
]).then(function(results){
|
|
2504
|
+
renderOverview({
|
|
2505
|
+
sessions: Array.isArray(results[0])?results[0]:[],
|
|
2506
|
+
stats: results[1]||{},
|
|
2507
|
+
telemetry: results[2]||{robots:[],totals:{}},
|
|
2508
|
+
history: Array.isArray(results[3])?results[3]:[]
|
|
2509
|
+
});
|
|
2510
|
+
}).catch(function(){ /* soft fail */ });
|
|
2511
|
+
}
|
|
2512
|
+
function renderOverview(data){
|
|
2513
|
+
var sessions = data.sessions||[];
|
|
2514
|
+
var stats = data.stats||{};
|
|
2515
|
+
var telemetry = data.telemetry||{};
|
|
2516
|
+
var totals = telemetry.totals || {};
|
|
2517
|
+
var robots = Array.isArray(telemetry.robots)?telemetry.robots:[];
|
|
2518
|
+
// active sessions
|
|
2519
|
+
var active = 0; for(var i=0;i<sessions.length;i++){ if(!sessions[i].ended_at) active++; }
|
|
2520
|
+
var totalTrig = totals.trigger_count!=null?totals.trigger_count:stats.total_sessions;
|
|
2521
|
+
var today = stats.today_sessions!=null?stats.today_sessions:0;
|
|
2522
|
+
var avgLat = (function(){
|
|
2523
|
+
var vals=[];
|
|
2524
|
+
for(var i=0;i<robots.length;i++){ if(robots[i].avg_latency_ms!=null) vals.push(robots[i].avg_latency_ms); }
|
|
2525
|
+
if(!vals.length) return null;
|
|
2526
|
+
return Math.round(vals.reduce(function(a,b){return a+b;},0)/vals.length);
|
|
2527
|
+
})();
|
|
2528
|
+
var dropRate = totals.drop_rate!=null?totals.drop_rate:null;
|
|
2529
|
+
var total = stats.total_sessions!=null?stats.total_sessions:0;
|
|
2530
|
+
// uptime: ratio of robots connected — best effort (we don't have an uptime API,
|
|
2531
|
+
// so we fall back to "robot count online" derived from telemetry robots)
|
|
2532
|
+
// Render the KPI strip
|
|
2533
|
+
function kpi(l,v,cls,sub){
|
|
2534
|
+
return '<div class="rp-kpi"><div class="l">'+esc(l)+'</div><div class="v '+esc(cls||'')+'">'+esc(v)+'</div>'+(sub?('<div class="s">'+esc(sub)+'</div>'):'')+'</div>';
|
|
2535
|
+
}
|
|
2536
|
+
var html = '';
|
|
2537
|
+
html += kpi('Triggers (total)', totalTrig!=null?totalTrig:'—', 'info', robots.length+' robot'+(robots.length===1?'':'s'));
|
|
2538
|
+
html += kpi('Today', today, today>0?'ok':'', 'sessions today');
|
|
2539
|
+
html += kpi('Active', active, active>0?'ok':'', 'live right now');
|
|
2540
|
+
html += kpi('Avg latency', avgLat!=null?(avgLat+'ms'):'—', avgLat==null?'':avgLat>=500?'bad':avgLat>=200?'warn':'ok', 'across all robots');
|
|
2541
|
+
html += kpi('Drop rate', dropRate!=null?Math.round(dropRate*100)+'%':'—', dropRate==null?'':dropRate>=0.2?'bad':dropRate>=0.05?'warn':'ok', 'failed / total');
|
|
2542
|
+
html += kpi('Total sessions', total, '', stats.avg_duration_seconds!=null?('avg '+rpFmtDuration(stats.avg_duration_seconds)):'');
|
|
2543
|
+
$('rp-overview-kpis').innerHTML = html;
|
|
2544
|
+
// mini chart: bucket sessions by hour for the last 60 minutes from started_at
|
|
2545
|
+
var buckets = new Array(12).fill(0); // 12 buckets × 5min = 60min
|
|
2546
|
+
var now = Date.now();
|
|
2547
|
+
for(var i=0;i<sessions.length;i++){
|
|
2548
|
+
var t = sessions[i].started_at ? Date.parse(sessions[i].started_at) : 0;
|
|
2549
|
+
if(!t) continue;
|
|
2550
|
+
var diff = now - t;
|
|
2551
|
+
if(diff<0 || diff>3600e3) continue;
|
|
2552
|
+
var idx = 11 - Math.floor(diff/(5*60e3));
|
|
2553
|
+
if(idx>=0 && idx<12) buckets[idx]++;
|
|
2554
|
+
}
|
|
2555
|
+
drawOverviewChart($('rp-overview-chart'), buckets);
|
|
2556
|
+
// top 3 robots by session count
|
|
2557
|
+
var ranked = robots.slice().sort(function(a,b){ return (b.sessions_total||0)-(a.sessions_total||0); });
|
|
2558
|
+
var top3 = ranked.slice(0,3);
|
|
2559
|
+
var top = $('rp-overview-top'), topMeta = $('rp-overview-top-meta');
|
|
2560
|
+
if(topMeta) topMeta.textContent = ranked.length?('of '+ranked.length+' robots'):'';
|
|
2561
|
+
if(!top3.length){ top.innerHTML = '<div class="empty" style="grid-column:1/-1;">No robot telemetry yet.</div>'; }
|
|
2562
|
+
else {
|
|
2563
|
+
var thtml='';
|
|
2564
|
+
for(var i=0;i<top3.length;i++){
|
|
2565
|
+
var x = top3[i];
|
|
2566
|
+
var name = x.name || x.id || 'robot';
|
|
2567
|
+
var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||name)});
|
|
2568
|
+
thtml += '<div class="rp-card" style="padding:0.55rem 0.7rem;">'
|
|
2569
|
+
+ '<div class="rp-card-top">'
|
|
2570
|
+
+ (avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')
|
|
2571
|
+
+ '<div style="min-width:0;flex:1;"><h4 style="margin:0;font-size:0.78rem;">'+esc(name)+'</h4>'
|
|
2572
|
+
+ '<div class="rp-metrics" style="grid-template-columns:repeat(3,1fr);gap:0.2rem 0.5rem;margin-top:0.3rem;">'
|
|
2573
|
+
+ '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.sessions_total||0)+'</span><span class="l">sessions</span></div>'
|
|
2574
|
+
+ '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.trigger_count||0)+'</span><span class="l">triggers</span></div>'
|
|
2575
|
+
+ '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—')+'</span><span class="l">latency</span></div>'
|
|
2576
|
+
+ '</div></div></div></div>';
|
|
2577
|
+
}
|
|
2578
|
+
top.innerHTML = thtml;
|
|
2579
|
+
}
|
|
2580
|
+
// recent activity stream
|
|
2581
|
+
var stream = $('rp-overview-stream'), smeta = $('rp-overview-stream-meta');
|
|
2582
|
+
if(smeta) smeta.textContent = 'last '+(data.history?data.history.length:0);
|
|
2583
|
+
var hist = data.history || [];
|
|
2584
|
+
if(!hist.length){ stream.innerHTML = '<div class="line"><span class="d" style="color:var(--muted)">no recent activity</span></div>'; }
|
|
2585
|
+
else {
|
|
2586
|
+
var hhtml='';
|
|
2587
|
+
for(var i=0;i<hist.length;i++){
|
|
2588
|
+
var ev = hist[i];
|
|
2589
|
+
var ts = ev.timestamp;
|
|
2590
|
+
var cat = ev.category || '';
|
|
2591
|
+
var act = ev.action || '';
|
|
2592
|
+
var ent = (ev.entity_type||'')+(ev.entity_id?(' · '+ev.entity_id):'');
|
|
2593
|
+
var det = ev.details || '';
|
|
2594
|
+
if(typeof det==='object') det = JSON.stringify(det);
|
|
2595
|
+
hhtml += '<div class="line"><span class="t">'+esc(clock(ts))+'</span>'
|
|
2596
|
+
+ '<span class="ic">'+esc(rpCategoryIcon(cat))+'</span>'
|
|
2597
|
+
+ '<span class="n" title="'+esc(cat)+'">'+esc(String(cat).slice(0,8))+'</span>'
|
|
2598
|
+
+ '<span class="d"><b>'+esc(act)+'</b> '+esc(ent)+(det?(' — '+esc(String(det).slice(0,60))):'')+'</span></div>';
|
|
2599
|
+
}
|
|
2600
|
+
stream.innerHTML = hhtml;
|
|
2601
|
+
}
|
|
2602
|
+
// update overview "now" indicator
|
|
2603
|
+
var nowEl = $('rp-overview-now'); if(nowEl) nowEl.textContent = sessions.length+' recent · '+active+' active';
|
|
2604
|
+
}
|
|
2605
|
+
function drawOverviewChart(canvas, buckets){
|
|
2606
|
+
if(!canvas || !canvas.getContext) return;
|
|
2607
|
+
var ctx = canvas.getContext('2d');
|
|
2608
|
+
var dpr = Math.max(1, Math.min(2, window.devicePixelRatio||1));
|
|
2609
|
+
var w = canvas.clientWidth, h = canvas.clientHeight;
|
|
2610
|
+
canvas.width = w*dpr; canvas.height = h*dpr;
|
|
2611
|
+
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
2612
|
+
ctx.clearRect(0,0,w,h);
|
|
2613
|
+
var padL=20, padR=8, padT=8, padB=14;
|
|
2614
|
+
var plotW = w - padL - padR, plotH = h - padT - padB;
|
|
2615
|
+
var mx = Math.max(1, Math.max.apply(null, buckets));
|
|
2616
|
+
// y grid (4 lines)
|
|
2617
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
|
2618
|
+
ctx.lineWidth = 1;
|
|
2619
|
+
for(var g=0;g<=4;g++){
|
|
2620
|
+
var y = padT + (plotH * g/4);
|
|
2621
|
+
ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(padL+plotW,y); ctx.stroke();
|
|
2622
|
+
ctx.fillStyle = 'rgba(158,147,180,0.6)';
|
|
2623
|
+
ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';
|
|
2624
|
+
ctx.fillText(Math.round(mx-(mx*g/4)), 2, y+3);
|
|
2625
|
+
}
|
|
2626
|
+
// bars
|
|
2627
|
+
var n = buckets.length, barW = plotW/n;
|
|
2628
|
+
for(var i=0;i<n;i++){
|
|
2629
|
+
var v = buckets[i];
|
|
2630
|
+
var bh = (v/mx)*plotH;
|
|
2631
|
+
var x = padL + i*barW + 1;
|
|
2632
|
+
var y = padT + (plotH - bh);
|
|
2633
|
+
try{
|
|
2634
|
+
var grad = ctx.createLinearGradient(0,y,0,y+bh);
|
|
2635
|
+
grad.addColorStop(0, 'rgba(255,216,107,0.95)');
|
|
2636
|
+
grad.addColorStop(1, 'rgba(230,178,60,0.55)');
|
|
2637
|
+
ctx.fillStyle = grad;
|
|
2638
|
+
}catch(e){ ctx.fillStyle = '#E6B23C'; }
|
|
2639
|
+
ctx.fillRect(x, y, Math.max(1,barW-2), bh);
|
|
2640
|
+
}
|
|
2641
|
+
// x axis labels
|
|
2642
|
+
ctx.fillStyle = 'rgba(158,147,180,0.7)';
|
|
2643
|
+
ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';
|
|
2644
|
+
ctx.fillText('-60m', padL, h-2);
|
|
2645
|
+
var mid = Math.round(n/2);
|
|
2646
|
+
ctx.fillText('-30m', padL + mid*barW, h-2);
|
|
2647
|
+
ctx.fillText('now', padL + (n-1)*barW - 16, h-2);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// ── init sub-tabs when the robopark tab is opened ──
|
|
2651
|
+
// We call buildRpSubTabs() the first time the user switches to the
|
|
2652
|
+
// Sessions & Telemetry main tab. From then on, setRpSubTab(name) reuses
|
|
2653
|
+
// it whenever the user picks a different sub-tab.
|
|
2654
|
+
var rpSubTabsInited = false;
|
|
2655
|
+
function initRpSubTabs(){
|
|
2656
|
+
if(rpSubTabsInited) return;
|
|
2657
|
+
rpSubTabsInited = true;
|
|
2658
|
+
buildRpSubTabs();
|
|
2659
|
+
}
|
|
2660
|
+
|
|
1241
2661
|
// ══════════════════════════════════════════════════════════════════
|
|
1242
2662
|
// Park — isometric live map of the mesh
|
|
1243
2663
|
// Real data: /fed/status nodes (placement + load) + /fed/stream frames
|
|
@@ -1247,12 +2667,39 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1247
2667
|
var Park = (function(){
|
|
1248
2668
|
var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
|
|
1249
2669
|
var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
|
|
1250
|
-
var
|
|
2670
|
+
var _lastScan = {};
|
|
2671
|
+
// Latest per-robot scheduler telemetry, keyed by scheduler robot name
|
|
2672
|
+
// (e.g. "jaguar") or id. Updated by pollRobopark() on every refresh
|
|
2673
|
+
// (every 3s) via Park.updateTelemetry(...). The Park node → telemetry
|
|
2674
|
+
// match is done in lookupTelemetry() by name.
|
|
2675
|
+
var _telemetryByName = {};
|
|
2676
|
+
// Remember the most recent in-session and ended timestamps per robot
|
|
2677
|
+
// so the "ended" ring can fade out for ~4s after a session ends,
|
|
2678
|
+
// and the "live/talking" status can persist briefly across polls.
|
|
2679
|
+
var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }
|
|
2680
|
+
var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, deviceSelectorsReady=false;
|
|
1251
2681
|
var previewOn=false, previewTimer=null; // on-demand operator camera preview
|
|
1252
2682
|
var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
|
|
1253
|
-
var lkRoom=null, micAnalyser=null, audioCtx=null;
|
|
2683
|
+
var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[];
|
|
1254
2684
|
var imgs={}; // preloaded robot concept avatars
|
|
1255
2685
|
var TW=44, TH=22, PARK_W=15, PARK_D=8;
|
|
2686
|
+
// Plaza footprint and parking lot (upper-right corner of the plaza)
|
|
2687
|
+
var P_X0=0.2, P_Y0=-0.10, P_X1=PARK_W-0.2, P_Y1=PARK_D-0.45;
|
|
2688
|
+
// Plaza outline as a polygon — top edge stays straight, bottom-left dips up where the pond
|
|
2689
|
+
// cuts into the plaza, and the right edge curves slightly inward to match the aerial.
|
|
2690
|
+
var PLAZA_POLY=[
|
|
2691
|
+
[0.4, -0.10],
|
|
2692
|
+
[8.6, -0.10],
|
|
2693
|
+
[8.6, 1.7 ],
|
|
2694
|
+
[14.6, 1.7 ],
|
|
2695
|
+
[14.6, 5.5 ],
|
|
2696
|
+
[3.0, 5.5 ],
|
|
2697
|
+
[2.5, 5.0 ],
|
|
2698
|
+
[0.4, 4.5 ]
|
|
2699
|
+
];
|
|
2700
|
+
var LOT_X0=9.0, LOT_Y0=-0.30, LOT_X1=14.8, LOT_Y1=1.6;
|
|
2701
|
+
// Beach strip at the very bottom edge of the park
|
|
2702
|
+
var BEACH_Y0=PARK_D-0.45, BEACH_Y1=PARK_D+0.3;
|
|
1256
2703
|
// The real park (from the aerial photo): named robot pads at their ground
|
|
1257
2704
|
// positions along the long Shenzhen Bay promenade plaza, plus palms, pond,
|
|
1258
2705
|
// street and stage. A mesh node binds to a pad when its display name matches
|
|
@@ -1260,18 +2707,121 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1260
2707
|
// SLOTS is now rebuilt from scheduler character_presets; keep the same shape.
|
|
1261
2708
|
var SLOTS = parkSlots.slice();
|
|
1262
2709
|
var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};
|
|
1263
|
-
// Palm positions
|
|
1264
|
-
//
|
|
2710
|
+
// Palm positions: two staggered rows on the plaza, between the robot pad columns.
|
|
2711
|
+
// Robot pads in normalized 0..1: HUB(0.06,0.16), VOLT(0.18,0.26), TESLA(0.38,0.30),
|
|
2712
|
+
// JAGUAR(0.58,0.34), VIXEN(0.40,0.68), PANDA(0.82,0.52)
|
|
2713
|
+
// Back row of palms at y=0.22, between the robots at the top of the plaza.
|
|
2714
|
+
// Front row at y=0.62, between/around the lower robots (VIXEN/PANDA).
|
|
2715
|
+
// Plus 2 grass palms near the upper-left for the tent area.
|
|
1265
2716
|
var PALMS=[
|
|
1266
|
-
[0.
|
|
1267
|
-
[0.
|
|
1268
|
-
[0.
|
|
1269
|
-
[0.62,0.80],[0.76,0.84],[0.35,0.82]
|
|
2717
|
+
[0.30,0.18],[0.50,0.18],[0.70,0.18],[0.90,0.18],
|
|
2718
|
+
[0.18,0.55],[0.28,0.55],[0.50,0.55],[0.65,0.55],
|
|
2719
|
+
[0.05,0.42],[0.05,0.62]
|
|
1270
2720
|
];
|
|
1271
2721
|
// Pond / curved water feature at the lower-left of the plaza.
|
|
1272
|
-
var POND={cx:0.
|
|
2722
|
+
var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
|
|
1273
2723
|
// Street traffic along the top road (yellow taxis + private cars).
|
|
1274
2724
|
var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];
|
|
2725
|
+
// ── Walking people — looping sprites that stroll along fixed plaza paths.
|
|
2726
|
+
// Each walker is a small canvas-drawn figure (head + body + two
|
|
2727
|
+
// swinging legs + swinging arms) with a vertical bob and a slight
|
|
2728
|
+
// body lean. A path is a list of [wx, wy] world points; the walker
|
|
2729
|
+
// eases between them at a per-walker speed, and "greets" any robot
|
|
2730
|
+
// pad it passes closely by pausing for ~1.4s in a wave pose while
|
|
2731
|
+
// the robot's pulse fires. Kept simple on purpose: no canvas
|
|
2732
|
+
// pathfinding, no random spawn, no state machine beyond a
|
|
2733
|
+
// pauseUntil timer. ──
|
|
2734
|
+
// Paths are aligned with the plaza chessboard so walkers appear to
|
|
2735
|
+
// step across the tiles. Coordinates are world units; the iso()
|
|
2736
|
+
// projection in drawWalker() handles the perspective.
|
|
2737
|
+
//
|
|
2738
|
+
// Six walkers total, distributed so at any moment at least a few are
|
|
2739
|
+
// visible across the plaza. segDurMs is how long a single segment
|
|
2740
|
+
// should take at this walker's pace — converting to a per-frame
|
|
2741
|
+
// step value happens in tickWalkers() so speeds are frame-rate
|
|
2742
|
+
// independent. ~3-4 seconds per segment reads as a calm stroll;
|
|
2743
|
+
// shorter = brisker.
|
|
2744
|
+
var WALKER_PATHS = [
|
|
2745
|
+
// Top of plaza — slow stroller between the hub and front row of robots. L→R.
|
|
2746
|
+
{ segDurMs: 5200, shirt:'#5b9bd5', pants:'#2a3f5c', pauseAt:[[3.5,0.4]] },
|
|
2747
|
+
// Lower half — loops past VIXEN and PANDA pads. R→L.
|
|
2748
|
+
{ segDurMs: 4200, shirt:'#c95b6f', pants:'#3a2a3a', pauseAt:[[11.0,5.1]] },
|
|
2749
|
+
// Mid-plaza — wanders past TESLA. L→R.
|
|
2750
|
+
{ segDurMs: 4600, shirt:'#7fb069', pants:'#3d4f2c', pauseAt:[[5.6,1.6]] },
|
|
2751
|
+
// Back of plaza — a quiet walker along the upper tiles. R→L.
|
|
2752
|
+
{ segDurMs: 6000, shirt:'#d68a3a', pants:'#4a3a26', pauseAt:[[8.0,-0.1]] },
|
|
2753
|
+
// Diagonal — crosses the plaza between JAGUAR and PANDA. L→R.
|
|
2754
|
+
{ segDurMs: 5000, shirt:'#a26fb0', pants:'#3a2a4a', pauseAt:[[7.0,3.4]] },
|
|
2755
|
+
// Slow elder at the front edge of the plaza. R→L.
|
|
2756
|
+
{ segDurMs: 7000, shirt:'#e8c39a', pants:'#5a4a2a', pauseAt:[[9.5,2.6]] },
|
|
2757
|
+
];
|
|
2758
|
+
// Per-walker offsets so they don't all start at the same phase of
|
|
2759
|
+
// their loop — makes the plaza feel populated at all times rather
|
|
2760
|
+
// than empty for the first few seconds. Offsets are fractions of
|
|
2761
|
+
// total path length (0..1) so they spread evenly.
|
|
2762
|
+
var WALKER_PHASE_OFFSETS = [0.00, 0.18, 0.42, 0.66, 0.82, 0.30];
|
|
2763
|
+
// Build the world-space path for each walker spec. Path idx chooses
|
|
2764
|
+
// the route; all paths live on the plaza chessboard so walkers look
|
|
2765
|
+
// like they step across the tiles.
|
|
2766
|
+
function buildWalkerPath(spec){
|
|
2767
|
+
if(spec.idx === 0){
|
|
2768
|
+
return [[1.0,0.3],[3.5,0.4],[7.5,0.4],[9.8,0.3],[9.8,1.6],[1.0,1.6]];
|
|
2769
|
+
}
|
|
2770
|
+
if(spec.idx === 1){
|
|
2771
|
+
return [[13.0,3.6],[11.0,5.1],[8.0,5.4],[5.0,5.2],[3.0,4.6],[1.4,3.4]];
|
|
2772
|
+
}
|
|
2773
|
+
if(spec.idx === 2){
|
|
2774
|
+
return [[0.4,2.2],[3.0,2.4],[5.6,1.6],[8.4,1.6],[11.2,2.0],[13.4,2.2]];
|
|
2775
|
+
}
|
|
2776
|
+
if(spec.idx === 3){
|
|
2777
|
+
return [[13.5,-0.1],[11.0,-0.05],[8.0,-0.1],[5.0,-0.05],[2.4,0.1],[2.4,0.9]];
|
|
2778
|
+
}
|
|
2779
|
+
if(spec.idx === 4){
|
|
2780
|
+
return [[0.4,4.0],[3.0,3.6],[7.0,3.4],[10.0,3.8],[12.6,4.2],[12.6,5.2]];
|
|
2781
|
+
}
|
|
2782
|
+
// idx 5
|
|
2783
|
+
return [[13.4,2.6],[9.5,2.6],[6.0,2.8],[3.0,2.6],[0.4,2.4],[0.4,1.6]];
|
|
2784
|
+
}
|
|
2785
|
+
// Cumulative arc-length table for each walker's polyline, used to
|
|
2786
|
+
// convert a phase offset (0..1) into a (seg, segT) starting position
|
|
2787
|
+
// and to drive the per-frame step so all walkers cover ground at a
|
|
2788
|
+
// consistent world-space pace regardless of segment length.
|
|
2789
|
+
function buildPathLengths(path){
|
|
2790
|
+
var lens = [0];
|
|
2791
|
+
var total = 0;
|
|
2792
|
+
for(var i=0; i<path.length-1; i++){
|
|
2793
|
+
var dx = path[i+1][0]-path[i][0], dy = path[i+1][1]-path[i][1];
|
|
2794
|
+
total += Math.sqrt(dx*dx + dy*dy);
|
|
2795
|
+
lens.push(total);
|
|
2796
|
+
}
|
|
2797
|
+
return { lens: lens, total: total || 1 };
|
|
2798
|
+
}
|
|
2799
|
+
function pathPosAt(pathInfo, dist){
|
|
2800
|
+
// Returns {seg, t} for the given arc-length distance along the path.
|
|
2801
|
+
var d = ((dist % pathInfo.total) + pathInfo.total) % pathInfo.total;
|
|
2802
|
+
for(var i=0; i<pathInfo.lens.length-1; i++){
|
|
2803
|
+
if(d <= pathInfo.lens[i+1]){
|
|
2804
|
+
var segLen = pathInfo.lens[i+1] - pathInfo.lens[i] || 1;
|
|
2805
|
+
return { seg: i, t: (d - pathInfo.lens[i]) / segLen };
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
return { seg: 0, t: 0 };
|
|
2809
|
+
}
|
|
2810
|
+
var walkers = WALKER_PATHS.map(function(spec, idx){
|
|
2811
|
+
var path = buildWalkerPath({idx:idx});
|
|
2812
|
+
var info = buildPathLengths(path);
|
|
2813
|
+
var phase = WALKER_PHASE_OFFSETS[idx % WALKER_PHASE_OFFSETS.length] || 0;
|
|
2814
|
+
var startPos = pathPosAt(info, phase * info.total);
|
|
2815
|
+
return {
|
|
2816
|
+
idx: idx,
|
|
2817
|
+
spec: spec,
|
|
2818
|
+
path: path,
|
|
2819
|
+
info: info,
|
|
2820
|
+
distance: startPos.seg + startPos.t, // current distance (in segments) along the path
|
|
2821
|
+
pauseUntil: 0, // perf.now() ms; walker holds while now < pauseUntil
|
|
2822
|
+
nextGreet: 0, // index into spec.pauseAt, for the next pause to fire
|
|
2823
|
+
};
|
|
2824
|
+
});
|
|
1275
2825
|
|
|
1276
2826
|
function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
|
|
1277
2827
|
function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
|
|
@@ -1337,6 +2887,93 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1337
2887
|
|
|
1338
2888
|
// Bind mesh nodes onto the fixed park pads; unmatched pads render as
|
|
1339
2889
|
// offline placeholders so the whole park is always visible.
|
|
2890
|
+
// ── Per-robot scheduler telemetry → status ──
|
|
2891
|
+
// pollRobopark() pushes the latest /robopark/api/telemetry rows into
|
|
2892
|
+
// Park.updateTelemetry(); we key them by robot name. layout() then
|
|
2893
|
+
// looks up each robot node by its slot aliases and attaches a
|
|
2894
|
+
// status + telemetry field used by drawRobot() to drive the
|
|
2895
|
+
// status animations.
|
|
2896
|
+
function updateTelemetry(map){
|
|
2897
|
+
_telemetryByName = map || {};
|
|
2898
|
+
}
|
|
2899
|
+
function lookupTelemetry(node){
|
|
2900
|
+
if(!node) return null;
|
|
2901
|
+
// The hub is never a per-robot telemetry row — only robot slots are.
|
|
2902
|
+
if(node.kind !== 'robot') return null;
|
|
2903
|
+
// Try every alias the slot was built with: slot.label, slot.id,
|
|
2904
|
+
// slot.sub, slot.keys, and the matched mesh node's displayName.
|
|
2905
|
+
var slot = null;
|
|
2906
|
+
for(var i=0;i<SLOTS.length;i++){ if(SLOTS[i].label === node.label){ slot = SLOTS[i]; break; } }
|
|
2907
|
+
var aliases = [];
|
|
2908
|
+
if(slot) aliases = aliases.concat(slotAliases(slot));
|
|
2909
|
+
if(node.name) aliases.push(String(node.name).toLowerCase());
|
|
2910
|
+
if(node.telemKeys) for(var tk=0; tk<node.telemKeys.length; tk++) aliases.push(node.telemKeys[tk]);
|
|
2911
|
+
// Try each alias as a case-insensitive key against the telemetry map.
|
|
2912
|
+
for(var ai=0; ai<aliases.length; ai++){
|
|
2913
|
+
var a = aliases[ai];
|
|
2914
|
+
if(!a) continue;
|
|
2915
|
+
for(var key in _telemetryByName){
|
|
2916
|
+
if(!Object.prototype.hasOwnProperty.call(_telemetryByName, key)) continue;
|
|
2917
|
+
var kl = String(key).toLowerCase();
|
|
2918
|
+
if(kl === a || kl.indexOf(a) >= 0 || a.indexOf(kl) >= 0) return _telemetryByName[key];
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
return null;
|
|
2922
|
+
}
|
|
2923
|
+
// Derive a coarse status string + a few extra flags from a
|
|
2924
|
+
// scheduler telemetry row. Status is one of: 'offline' | 'ready'
|
|
2925
|
+
// | 'live' | 'talking' | 'ended'. We also expose the raw readiness
|
|
2926
|
+
// string so the existing label can still read it.
|
|
2927
|
+
function computeStatus(tele, node){
|
|
2928
|
+
if(!node || node.kind !== 'robot') return { status:'idle', readiness:null };
|
|
2929
|
+
if(!node.connected) return { status:'offline', readiness:null };
|
|
2930
|
+
var mem = _statusMem[node.id] || (_statusMem[node.id] = { lastActiveAt:0, lastEndedAt:0, prevReadiness:null });
|
|
2931
|
+
var readiness = tele ? (tele.readiness_code || 'unknown') : null;
|
|
2932
|
+
var hasSession = !!(tele && (tele.current_session_id || tele.active_session || tele.in_session || tele.session_active));
|
|
2933
|
+
var sessionAge = tele ? (tele.session_age_seconds != null ? tele.session_age_seconds : null) : null;
|
|
2934
|
+
var activityAge = tele ? (tele.session_activity_age_seconds != null ? tele.session_activity_age_seconds : null) : null;
|
|
2935
|
+
var now = performance.now();
|
|
2936
|
+
// mark transitions
|
|
2937
|
+
if(readiness === 'in_session' && mem.prevReadiness !== 'in_session'){
|
|
2938
|
+
mem.lastActiveAt = now;
|
|
2939
|
+
}
|
|
2940
|
+
if(mem.prevReadiness === 'in_session' && readiness !== 'in_session'){
|
|
2941
|
+
mem.lastEndedAt = now;
|
|
2942
|
+
}
|
|
2943
|
+
mem.prevReadiness = readiness;
|
|
2944
|
+
// decide status
|
|
2945
|
+
var status = 'ready';
|
|
2946
|
+
if(hasSession){
|
|
2947
|
+
// active session. "talking" if the session has had activity
|
|
2948
|
+
// within the last 2.5s; otherwise "live" (idle but connected).
|
|
2949
|
+
if(activityAge != null && activityAge <= 2.5) status = 'talking';
|
|
2950
|
+
else status = 'live';
|
|
2951
|
+
} else if(readiness === 'in_session'){
|
|
2952
|
+
// server says in_session but we don't have the session id on
|
|
2953
|
+
// this row — treat as live.
|
|
2954
|
+
status = 'live';
|
|
2955
|
+
} else if(readiness && /stuck_session|stale_session/.test(readiness)){
|
|
2956
|
+
status = 'live'; // still a session going on, just unhealthy
|
|
2957
|
+
} else if(readiness === 'ready'){
|
|
2958
|
+
// Show "ended" ring for ~4s after a session ends, then drop back
|
|
2959
|
+
// to the calm "ready" (waiting) state.
|
|
2960
|
+
if(mem.lastEndedAt && (now - mem.lastEndedAt) < 4000) status = 'ended';
|
|
2961
|
+
else status = 'ready';
|
|
2962
|
+
} else if(readiness && /production_off|production_off_robot|device_missing|server_unavailable|heartbeat_stale/.test(readiness)){
|
|
2963
|
+
status = 'ready'; // still ready to talk, just gated
|
|
2964
|
+
}
|
|
2965
|
+
return {
|
|
2966
|
+
status: status,
|
|
2967
|
+
readiness: readiness,
|
|
2968
|
+
readinessMessage: tele ? tele.readiness_message : null,
|
|
2969
|
+
sessionAge: sessionAge,
|
|
2970
|
+
activityAge: activityAge,
|
|
2971
|
+
triggerCount: tele ? tele.trigger_count : null,
|
|
2972
|
+
drops: tele ? tele.drops : null,
|
|
2973
|
+
dropRate: tele ? tele.drop_rate : null,
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
|
|
1340
2977
|
function layout(src){
|
|
1341
2978
|
var list=(src||[]).slice(), used={}, out=[];
|
|
1342
2979
|
function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
|
|
@@ -1349,7 +2986,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1349
2986
|
}
|
|
1350
2987
|
var id = match?match.nodeId:('pad:'+slot.label);
|
|
1351
2988
|
if(match) used[match.nodeId]=1;
|
|
1352
|
-
|
|
2989
|
+
var node = {
|
|
1353
2990
|
id:id, kind:kind, label:slot.label,
|
|
1354
2991
|
name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
|
|
1355
2992
|
sub: match?(slot.sub||''):'not joined',
|
|
@@ -1360,6 +2997,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1360
2997
|
telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
|
|
1361
2998
|
wx:wx, wy:wy, _hit:prevHit(id)
|
|
1362
2999
|
};
|
|
3000
|
+
// Attach a status derived from the latest scheduler telemetry
|
|
3001
|
+
// (push-poll, so it can lag up to 3s behind /fed/status).
|
|
3002
|
+
if(kind === 'robot'){
|
|
3003
|
+
var tele = lookupTelemetry(node);
|
|
3004
|
+
var st = computeStatus(tele, node);
|
|
3005
|
+
node.telemetry = tele;
|
|
3006
|
+
node.status = st.status;
|
|
3007
|
+
node.readiness = st.readiness;
|
|
3008
|
+
node.readinessMessage = st.readinessMessage;
|
|
3009
|
+
node.sessionAge = st.sessionAge;
|
|
3010
|
+
node.activityAge = st.activityAge;
|
|
3011
|
+
}
|
|
3012
|
+
return node;
|
|
1363
3013
|
}
|
|
1364
3014
|
var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
|
|
1365
3015
|
SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
|
|
@@ -1462,64 +3112,801 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1462
3112
|
if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
|
|
1463
3113
|
}
|
|
1464
3114
|
function drawGround(){
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1470
|
-
|
|
3115
|
+
var z=cam.z;
|
|
3116
|
+
// ── grass base — multi-stop radial sun-lit gradient on a slightly larger field ──
|
|
3117
|
+
// Field is now sized to the plaza footprint + a small margin. The previous
|
|
3118
|
+
// version extended from x=-0.5..PARK_W+0.5, which left a wide empty green
|
|
3119
|
+
// strip on either side of the plaza; trimmed here to remove that overhang.
|
|
3120
|
+
var parkCx=PARK_W/2, parkCy=PARK_D/2;
|
|
3121
|
+
var gCent=iso(parkCx,parkCy);
|
|
3122
|
+
var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);
|
|
3123
|
+
gRad.addColorStop(0,'rgba(154,196,108,0.95)');
|
|
3124
|
+
gRad.addColorStop(0.45,'rgba(86,142,80,0.95)');
|
|
3125
|
+
gRad.addColorStop(1,'rgba(44,98,52,0.95)');
|
|
3126
|
+
// grass field — tight margin around the plaza + lot, NOT covering the road or the beach
|
|
3127
|
+
var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;
|
|
3128
|
+
poly([[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]], gRad, 'rgba(140,200,150,0.10)',1);
|
|
3129
|
+
// ── "mowed stripes" effect — wide diagonal bands of alternating green tones ──
|
|
3130
|
+
// Drawn as full-width rectangles in world space, slightly rotated via a parallelogram skew.
|
|
3131
|
+
X.save();
|
|
3132
|
+
var stripeColors=['rgba(108,162,90,0.45)','rgba(86,140,72,0.35)'];
|
|
3133
|
+
var stripeCount=5;
|
|
3134
|
+
var stripeW=(GF_X1-GF_X0+GF_Y1-GF_Y0+2)/stripeCount;
|
|
3135
|
+
for(var si=0;si<stripeCount;si++){
|
|
3136
|
+
var off=si*stripeW - ((GF_X1-GF_X0)+(GF_Y1-GF_Y0))*0.5;
|
|
3137
|
+
// Build a parallelogram in world coords that runs from left-bottom to right-top
|
|
3138
|
+
// at a slight angle (skewed +X, -Y direction) — looks like a stadium mowed lawn.
|
|
3139
|
+
// Tightly fitted to the grass field bounds.
|
|
3140
|
+
var p1=[GF_X0 + off, GF_Y1];
|
|
3141
|
+
var p2=[GF_X0 + off + 1.0, GF_Y0];
|
|
3142
|
+
var p3=[GF_X0 + off + 1.0 + (GF_X1-GF_X0), GF_Y0];
|
|
3143
|
+
var p4=[GF_X0 + off + (GF_X1-GF_X0), GF_Y1];
|
|
3144
|
+
poly([p1,p2,p3,p4], stripeColors[si%2], null, 0);
|
|
3145
|
+
}
|
|
3146
|
+
X.restore();
|
|
3147
|
+
// ── outer hedge / foliage border just inside the grass field ──
|
|
3148
|
+
// Slightly ragged inner edge: extra points along each side to break the straight line.
|
|
3149
|
+
// The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).
|
|
3150
|
+
var hedgeW=0.9; // world units thick
|
|
3151
|
+
var pTL=[GF_X0,GF_Y0], pTR=[GF_X1,GF_Y0], pBR=[GF_X1,GF_Y1], pBL=[GF_X0,GF_Y1];
|
|
3152
|
+
// Build a ragged inner-edge polygon: along the inner edge we add jitter points.
|
|
3153
|
+
// We use 4 sides, each with 4 extra points (5 segments per side), jitter ±0.18.
|
|
3154
|
+
function jx(seed){ return ((Math.sin(seed*12.9898)*43758.5453)%1+1)%1; }
|
|
3155
|
+
function hedgeInnerPts(){
|
|
3156
|
+
// Returns an inner ring (hedge width hedgeW from outer), starting at top-left and going CW.
|
|
3157
|
+
var pts=[];
|
|
3158
|
+
// top edge: from pTL toward pTR (jitter along y)
|
|
3159
|
+
var segs=6;
|
|
3160
|
+
for(var i=0;i<=segs;i++){
|
|
3161
|
+
var t=i/segs;
|
|
3162
|
+
var x=pTL[0]+(pTR[0]-pTL[0])*t;
|
|
3163
|
+
var y=pTL[1]+hedgeW + (jx(i*3+1)-0.5)*0.34;
|
|
3164
|
+
pts.push([x,y]);
|
|
3165
|
+
}
|
|
3166
|
+
// right edge: from pTR toward pBR (jitter along x)
|
|
3167
|
+
for(var i2=1;i2<=segs;i2++){
|
|
3168
|
+
var t2=i2/segs;
|
|
3169
|
+
var x2=pTR[0]-hedgeW + (jx(i2*5+7)-0.5)*0.34;
|
|
3170
|
+
var y2=pTR[1]+(pBR[1]-pTR[1])*t2;
|
|
3171
|
+
pts.push([x2,y2]);
|
|
3172
|
+
}
|
|
3173
|
+
// bottom edge: from pBR toward pBL (jitter along y, reversed)
|
|
3174
|
+
for(var i3=1;i3<=segs;i3++){
|
|
3175
|
+
var t3=i3/segs;
|
|
3176
|
+
var x3=pBR[0]+(pBL[0]-pBR[0])*t3;
|
|
3177
|
+
var y3=pBR[1]-hedgeW + (jx(i3*7+13)-0.5)*0.34;
|
|
3178
|
+
pts.push([x3,y3]);
|
|
3179
|
+
}
|
|
3180
|
+
// left edge: from pBL toward pTL (jitter along x, reversed)
|
|
3181
|
+
for(var i4=1;i4<segs;i4++){
|
|
3182
|
+
var t4=i4/segs;
|
|
3183
|
+
var x4=pBL[0]+hedgeW + (jx(i4*11+19)-0.5)*0.34;
|
|
3184
|
+
var y4=pBL[1]+(pTL[1]-pBL[1])*t4;
|
|
3185
|
+
pts.push([x4,y4]);
|
|
3186
|
+
}
|
|
3187
|
+
return pts;
|
|
3188
|
+
}
|
|
3189
|
+
var innerRing=hedgeInnerPts();
|
|
3190
|
+
// full grass outer polygon = outer rect, then inner ring (use even-odd via closePath)
|
|
3191
|
+
X.save();
|
|
3192
|
+
X.beginPath();
|
|
3193
|
+
var outerRect=[[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]];
|
|
3194
|
+
for(var oi=0;oi<outerRect.length;oi++){ var op=iso(outerRect[oi][0],outerRect[oi][1]); if(oi===0)X.moveTo(op.x,op.y); else X.lineTo(op.x,op.y); }
|
|
3195
|
+
X.closePath();
|
|
3196
|
+
for(var ii=0;ii<innerRing.length;ii++){ var ip=iso(innerRing[ii][0],innerRing[ii][1]); if(ii===0)X.moveTo(ip.x,ip.y); else X.lineTo(ip.x,ip.y); }
|
|
3197
|
+
X.closePath();
|
|
3198
|
+
X.fillStyle='rgba(28,68,38,0.95)'; X.fill('evenodd');
|
|
3199
|
+
X.restore();
|
|
3200
|
+
// thin lighter inner rim ~0.15 inside the inner edge
|
|
3201
|
+
function innerRimPts(){
|
|
3202
|
+
var rim=[];
|
|
3203
|
+
var segs=6;
|
|
3204
|
+
for(var i=0;i<=segs;i++){ var t=i/segs; rim.push([(pTL[0]+(pTR[0]-pTL[0])*t), (pTL[1]+hedgeW-0.15) + (jx(i*3+1)-0.5)*0.30]); }
|
|
3205
|
+
for(var i2=1;i2<=segs;i2++){ var t2=i2/segs; rim.push([(pTR[0]-hedgeW+0.15) + (jx(i2*5+7)-0.5)*0.30, (pTR[1]+(pBR[1]-pTR[1])*t2)]); }
|
|
3206
|
+
for(var i3=1;i3<=segs;i3++){ var t3=i3/segs; rim.push([(pBR[0]+(pBL[0]-pBR[0])*t3), (pBR[1]-hedgeW+0.15) + (jx(i3*7+13)-0.5)*0.30]); }
|
|
3207
|
+
for(var i4=1;i4<segs;i4++){ var t4=i4/segs; rim.push([(pBL[0]+hedgeW-0.15) + (jx(i4*11+19)-0.5)*0.30, (pBL[1]+(pTL[1]-pBL[1])*t4)]); }
|
|
3208
|
+
return rim;
|
|
3209
|
+
}
|
|
3210
|
+
var rimPts=innerRimPts();
|
|
3211
|
+
X.save();
|
|
3212
|
+
X.beginPath();
|
|
3213
|
+
for(var ri2=0;ri2<rimPts.length;ri2++){ var rp=iso(rimPts[ri2][0],rimPts[ri2][1]); if(ri2===0)X.moveTo(rp.x,rp.y); else X.lineTo(rp.x,rp.y); }
|
|
3214
|
+
X.closePath();
|
|
3215
|
+
X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();
|
|
3216
|
+
X.restore();
|
|
3217
|
+
// grass noise — scattered lighter tufts to add texture (warm bright-green tints)
|
|
3218
|
+
X.save();
|
|
3219
|
+
for(var ni=0;ni<60;ni++){
|
|
3220
|
+
var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);
|
|
3221
|
+
var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);
|
|
3222
|
+
var gp=iso(gnx,gny);
|
|
3223
|
+
X.fillStyle=(ni%2)?'rgba(150,200,110,0.20)':'rgba(56,112,62,0.22)';
|
|
3224
|
+
X.beginPath(); X.arc(gp.x+((ni*7)%13-6)*z,gp.y+((ni*11)%9-4)*z,1.6*z,0,7); X.fill();
|
|
3225
|
+
}
|
|
3226
|
+
X.restore();
|
|
3227
|
+
// ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
|
|
3228
|
+
X.save();
|
|
3229
|
+
var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];
|
|
3230
|
+
for(var fi=0;fi<34;fi++){
|
|
3231
|
+
var fx=GF_X0 + jx(fi*17+3)*(GF_X1-GF_X0);
|
|
3232
|
+
var fy=hedgeW + jx(fi*23+11)*(GF_Y1-hedgeW*1.4);
|
|
3233
|
+
// skip plaza area (P_X0..P_X1, P_Y0..P_Y1) and pond area
|
|
3234
|
+
if(fx>=P_X0-0.3 && fx<=P_X1+0.3 && fy>=P_Y0-0.3 && fy<=P_Y1+0.3) continue;
|
|
3235
|
+
var dxp=fx-POND.cx, dyp=fy-POND.cy;
|
|
3236
|
+
if(dxp*dxp/(POND.rx*POND.rx) + dyp*dyp/(POND.ry*POND.ry) < 1.1) continue;
|
|
3237
|
+
var fp=iso(fx,fy);
|
|
3238
|
+
X.fillStyle=flowerColors[fi%3];
|
|
3239
|
+
X.beginPath(); X.arc(fp.x+((fi*5)%7-3)*z, fp.y+((fi*9)%5-2)*z, 1.4*z + (fi%2)*0.5*z, 0, 7); X.fill();
|
|
3240
|
+
}
|
|
3241
|
+
X.restore();
|
|
3242
|
+
// beach strip at the very bottom (sandy)
|
|
3243
|
+
poly([[-1,BEACH_Y0],[PARK_W+1,BEACH_Y0],[PARK_W+1,BEACH_Y1],[-1,BEACH_Y1]],
|
|
3244
|
+
'rgba(214,196,150,0.55)', 'rgba(180,160,120,0.5)', 1);
|
|
3245
|
+
// darker curb along the paved edge (drawn as a thin offset quad)
|
|
3246
|
+
var curbOff=0.12;
|
|
3247
|
+
var curbPoly=PLAZA_POLY.map(function(p){
|
|
3248
|
+
// expand outward a bit so the curb ring is visible just outside the plaza footprint
|
|
3249
|
+
return [p[0] + (p[0]<1?-curbOff:(p[0]>14?curbOff:0)), p[1] + (p[1]<-0.05?-curbOff:(p[1]>5?curbOff:0))];
|
|
3250
|
+
});
|
|
3251
|
+
poly(curbPoly, 'rgba(20,28,22,0.85)', null, 0);
|
|
3252
|
+
// paved plaza base — neutral stone so the tile pattern reads clearly (polygon outline)
|
|
3253
|
+
var pg=X.createLinearGradient(0,0,0,H);
|
|
3254
|
+
pg.addColorStop(0,'rgba(170,162,150,0.92)');
|
|
3255
|
+
pg.addColorStop(1,'rgba(132,124,114,0.92)');
|
|
3256
|
+
poly(PLAZA_POLY, pg, 'rgba(60,52,44,0.55)',1);
|
|
3257
|
+
// chessboard stone tile pattern — iterate a rectangle then clip to the plaza polygon
|
|
3258
|
+
X.save();
|
|
3259
|
+
X.beginPath();
|
|
3260
|
+
for(var pli=0;pli<PLAZA_POLY.length;pli++){
|
|
3261
|
+
var plp=iso(PLAZA_POLY[pli][0],PLAZA_POLY[pli][1]);
|
|
3262
|
+
if(pli===0) X.moveTo(plp.x,plp.y); else X.lineTo(plp.x,plp.y);
|
|
3263
|
+
}
|
|
3264
|
+
X.closePath();
|
|
3265
|
+
X.clip();
|
|
3266
|
+
var tile=1.5;
|
|
3267
|
+
var tx0=Math.floor(P_X0/tile)*tile, ty0=Math.floor(P_Y0/tile)*tile;
|
|
3268
|
+
var lite='rgba(206,200,188,0.92)', dark='rgba(118,108,98,0.92)';
|
|
3269
|
+
var liteBevel='rgba(238,232,218,0.55)', darkShade='rgba(60,52,44,0.45)';
|
|
3270
|
+
for(var gy2=ty0;gy2<P_Y1+1;gy2+=tile){
|
|
3271
|
+
for(var gx2=tx0;gx2<P_X1+1;gx2+=tile){
|
|
3272
|
+
var x0=gx2, y0=gy2, x1=gx2+tile, y1=gy2+tile;
|
|
3273
|
+
if(x1>P_X1) x1=P_X1; if(y1>P_Y1) y1=P_Y1;
|
|
3274
|
+
var isLite=(((Math.round((x0-P_X0)/tile)+Math.round((y0-P_Y0)/tile))%2)+2)%2===0;
|
|
3275
|
+
var top=isLite?lite:dark;
|
|
3276
|
+
// top face
|
|
3277
|
+
poly([[x0,y0],[x1,y0],[x1,y1],[x0,y1]], top, null, 0);
|
|
3278
|
+
// bevel: lighter top-left edge, darker bottom-right edge (use iso projection edge lines)
|
|
3279
|
+
var tlA=iso(x0,y0), tlB=iso(x1,y0), tlC=iso(x1,y1), tlD=iso(x0,y1);
|
|
3280
|
+
// top edge (lit)
|
|
3281
|
+
X.strokeStyle=isLite?liteBevel:'rgba(160,150,140,0.35)'; X.lineWidth=0.9*z;
|
|
3282
|
+
X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlB.x,tlB.y); X.stroke();
|
|
3283
|
+
// left edge (lit)
|
|
3284
|
+
X.strokeStyle=isLite?liteBevel:'rgba(150,140,130,0.30)';
|
|
3285
|
+
X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlD.x,tlD.y); X.stroke();
|
|
3286
|
+
// bottom edge (shaded)
|
|
3287
|
+
X.strokeStyle=darkShade;
|
|
3288
|
+
X.beginPath(); X.moveTo(tlD.x,tlD.y); X.lineTo(tlC.x,tlC.y); X.stroke();
|
|
3289
|
+
// right edge (shaded)
|
|
3290
|
+
X.strokeStyle=darkShade;
|
|
3291
|
+
X.beginPath(); X.moveTo(tlB.x,tlB.y); X.lineTo(tlC.x,tlC.y); X.stroke();
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
X.restore();
|
|
3295
|
+
// outer plaza border for a clean curb (use the polygon)
|
|
3296
|
+
poly(PLAZA_POLY, null, 'rgba(50,44,36,0.7)', 1.4*z);
|
|
3297
|
+
// ── sun-shade pavilions on the plaza (4 of them) ──
|
|
3298
|
+
// (pavilions removed — the chessboard plaza reads better without canopies)
|
|
3299
|
+
// parking lot — upper-right corner of the plaza (after the road)
|
|
3300
|
+
// curb ring around the lot
|
|
3301
|
+
poly([[LOT_X0-0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y1+0.08],[LOT_X0-0.08,LOT_Y1+0.08]],
|
|
3302
|
+
'rgba(20,20,24,0.95)', null, 0);
|
|
3303
|
+
// asphalt surface
|
|
3304
|
+
poly([[LOT_X0,LOT_Y0],[LOT_X1,LOT_Y0],[LOT_X1,LOT_Y1],[LOT_X0,LOT_Y1]],
|
|
3305
|
+
'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.9)', 1);
|
|
3306
|
+
// access driveway from lot down into the plaza (thin asphalt strip)
|
|
3307
|
+
poly([[8.5, 1.5],[10.0, 1.5],[10.6, 2.5],[9.1, 2.5]],
|
|
3308
|
+
'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.85)', 0.8*z);
|
|
3309
|
+
// parking bay lines (white) — 2 rows
|
|
3310
|
+
X.strokeStyle='rgba(238,238,232,0.85)'; X.lineWidth=1.1*z; X.setLineDash([]);
|
|
3311
|
+
var bays=6;
|
|
3312
|
+
var lotH=LOT_Y1-LOT_Y0;
|
|
3313
|
+
var rowYs=[LOT_Y0+lotH*0.32, LOT_Y0+lotH*0.70];
|
|
3314
|
+
for(var ri2=0;ri2<rowYs.length;ri2++){
|
|
3315
|
+
for(var bi=1;bi<bays;bi++){
|
|
3316
|
+
var bx=LOT_X0+bi*((LOT_X1-LOT_X0)/bays);
|
|
3317
|
+
var ba=iso(bx, rowYs[ri2]-0.18), bb=iso(bx, rowYs[ri2]+0.18);
|
|
3318
|
+
X.beginPath(); X.moveTo(ba.x,ba.y); X.lineTo(bb.x,bb.y); X.stroke();
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
// front edge line
|
|
3322
|
+
var feA=iso(LOT_X0+0.12,LOT_Y1-0.22), feB=iso(LOT_X1-0.12,LOT_Y1-0.22);
|
|
3323
|
+
X.beginPath(); X.moveTo(feA.x,feA.y); X.lineTo(feB.x,feB.y); X.stroke();
|
|
3324
|
+
// back edge line
|
|
3325
|
+
var beA=iso(LOT_X0+0.12,LOT_Y0+0.22), beB=iso(LOT_X1-0.12,LOT_Y0+0.22);
|
|
3326
|
+
X.beginPath(); X.moveTo(beA.x,beA.y); X.lineTo(beB.x,beB.y); X.stroke();
|
|
3327
|
+
// parked cars in 2 rows
|
|
3328
|
+
var bayW=(LOT_X1-LOT_X0)/bays;
|
|
3329
|
+
var parkedColors=['#d44a4a','#5b8bd6','#e8e6dc','#f2c14e','#7fb4ff','#5fa060','#c87ed4','#e0a23c','#9aa0a8','#ff7e6e'];
|
|
3330
|
+
for(var pri=0;pri<rowYs.length;pri++){
|
|
3331
|
+
for(var pbi=0;pbi<bays-1;pbi++){
|
|
3332
|
+
var pcx=LOT_X0+0.7+pbi*bayW;
|
|
3333
|
+
if(pbi===0 || pbi===bays-2) continue; // skip end bays for some breathing room
|
|
3334
|
+
drawCar(pcx, rowYs[pri], parkedColors[(pri*bays+pbi)%parkedColors.length]);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
// street band along the far edge + dashed centerline + road cars
|
|
3338
|
+
poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.92)', 'rgba(255,255,255,0.08)',1);
|
|
3339
|
+
X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*z]);
|
|
1471
3340
|
var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);
|
|
1472
3341
|
CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
|
|
1473
|
-
// white event tent
|
|
1474
|
-
drawTent(0.
|
|
1475
|
-
//
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
3342
|
+
// white event tent in the left grass strip (west of the plaza)
|
|
3343
|
+
drawTent(-0.15, 0.5);
|
|
3344
|
+
// ── lake — wider sandy shore with soft radial fade to grass ──
|
|
3345
|
+
// shore ring: a wider light sand band outside the water with a soft radial fade.
|
|
3346
|
+
X.save();
|
|
3347
|
+
var shoreRings=18;
|
|
3348
|
+
for(var shr=shoreRings; shr>=0; shr--){
|
|
3349
|
+
var t=shr/shoreRings;
|
|
3350
|
+
var rx=POND.rx + 0.5*(1-t);
|
|
3351
|
+
var ry=POND.ry + 0.5*(1-t);
|
|
3352
|
+
var alpha=0.55*(1-t)*(1-t);
|
|
3353
|
+
var col='rgba(228,210,168,'+alpha.toFixed(3)+')';
|
|
3354
|
+
var ringPts=[];
|
|
3355
|
+
for(var sk=0;sk<Math.PI*2;sk+=Math.PI/16){
|
|
3356
|
+
ringPts.push([POND.cx+rx*Math.cos(sk), POND.cy+ry*Math.sin(sk)*0.7]);
|
|
3357
|
+
}
|
|
3358
|
+
poly(ringPts, col, null, 0);
|
|
3359
|
+
}
|
|
3360
|
+
X.restore();
|
|
3361
|
+
// thin outer foam ring (kept for sparkle)
|
|
3362
|
+
var foamPts=[];
|
|
3363
|
+
for(var tf=0;tf<Math.PI*2;tf+=Math.PI/16){
|
|
3364
|
+
foamPts.push([POND.cx+(POND.rx+0.06)*Math.cos(tf), POND.cy+(POND.ry+0.05)*Math.sin(tf)*0.7]);
|
|
3365
|
+
}
|
|
3366
|
+
poly(foamPts, 'rgba(232,244,248,0.45)', 'rgba(255,255,255,0.5)', 1.0*z);
|
|
3367
|
+
// water outline (outer ellipse)
|
|
1484
3368
|
var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
//
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
3369
|
+
// ── lake bed — 3 concentric ellipses with progressively darker teal/blue, slightly offset toward upper-back ──
|
|
3370
|
+
function lakeEllipse(cx,cy,rx,ry,offX,offY){ var a=[]; for(var k=0;k<Math.PI*2;k+=Math.PI/16){ a.push([cx+offX+rx*Math.cos(k), cy+offY+ry*Math.sin(k)*0.7]); } return a; }
|
|
3371
|
+
// Outer: teal
|
|
3372
|
+
poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.70, POND.ry*0.70, -0.10, -0.06), 'rgba(112,196,212,0.55)', null, 0);
|
|
3373
|
+
// Mid: deeper teal
|
|
3374
|
+
poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.45, POND.ry*0.45, -0.18, -0.10), 'rgba(56,148,178,0.78)', null, 0);
|
|
3375
|
+
// Inner: deep blue
|
|
3376
|
+
poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.22, POND.ry*0.22, -0.22, -0.12), 'rgba(20,82,124,0.92)', null, 0);
|
|
3377
|
+
// water surface outline (drawn on top of the depth layers for a clean edge)
|
|
3378
|
+
poly(pts, null, 'rgba(120,200,222,0.6)',1.6*z);
|
|
3379
|
+
// ── sun-glare streak — translucent white diagonal ellipse at upper-right of lake ──
|
|
3380
|
+
var glareCx=POND.cx+0.55, glareCy=POND.cy-0.55;
|
|
3381
|
+
var glareP=iso(glareCx, glareCy);
|
|
3382
|
+
var glareGrad=X.createRadialGradient(glareP.x, glareP.y, 2*z, glareP.x, glareP.y, 28*z);
|
|
3383
|
+
glareGrad.addColorStop(0,'rgba(255,255,255,0.32)');
|
|
3384
|
+
glareGrad.addColorStop(0.6,'rgba(255,255,255,0.10)');
|
|
3385
|
+
glareGrad.addColorStop(1,'rgba(255,255,255,0)');
|
|
3386
|
+
X.save();
|
|
3387
|
+
X.translate(glareP.x, glareP.y);
|
|
3388
|
+
X.rotate(-Math.PI/4);
|
|
3389
|
+
X.fillStyle=glareGrad;
|
|
3390
|
+
X.beginPath(); X.ellipse(0, 0, 26*z, 6*z, 0, 0, Math.PI*2); X.fill();
|
|
3391
|
+
X.restore();
|
|
3392
|
+
// ── animated ripple/highlight — thin white elliptical arcs that drift across the lake ──
|
|
3393
|
+
var pondCent=iso(POND.cx, POND.cy);
|
|
3394
|
+
var ripplePhase=Math.sin(performance.now()/1500);
|
|
3395
|
+
X.save();
|
|
3396
|
+
X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';
|
|
3397
|
+
for(var rk=0;rk<4;rk++){
|
|
3398
|
+
var drift=(rk*0.6 + ripplePhase*0.8) % (POND.rx*1.4) - POND.rx*0.7;
|
|
3399
|
+
var rxr2=Math.abs(ripplePhase)*POND.rx*0.35 + POND.rx*0.18 + rk*0.18;
|
|
3400
|
+
var ryr2=POND.ry*0.18 + rk*0.10;
|
|
3401
|
+
var rCx2=POND.cx + drift*0.4;
|
|
3402
|
+
var rCy2=POND.cy - 0.10 + rk*0.12;
|
|
3403
|
+
var rA2=iso(rCx2-rxr2, rCy2);
|
|
3404
|
+
var rB2=iso(rCx2+rxr2, rCy2);
|
|
3405
|
+
X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(performance.now()/900 + rk));
|
|
3406
|
+
X.beginPath();
|
|
3407
|
+
X.moveTo(rA2.x, rA2.y);
|
|
3408
|
+
X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);
|
|
3409
|
+
X.stroke();
|
|
3410
|
+
}
|
|
3411
|
+
X.globalAlpha=1;
|
|
3412
|
+
X.restore();
|
|
3413
|
+
// ── lily pads — small green ellipse clusters at deterministic positions ──
|
|
3414
|
+
var lilySpots=[
|
|
3415
|
+
{x: POND.cx-1.8, y: POND.cy+0.25, s: 0.95},
|
|
3416
|
+
{x: POND.cx+1.5, y: POND.cy+0.45, s: 1.05},
|
|
3417
|
+
{x: POND.cx-0.3, y: POND.cy-0.55, s: 0.85},
|
|
3418
|
+
{x: POND.cx+2.1, y: POND.cy-0.35, s: 0.9}
|
|
3419
|
+
];
|
|
3420
|
+
for(var li=0;li<lilySpots.length;li++){
|
|
3421
|
+
var lp=iso(lilySpots[li].x, lilySpots[li].y);
|
|
3422
|
+
var lsz=lilySpots[li].s*z;
|
|
3423
|
+
// lily pad: ellipse with a notch cut from the right side
|
|
3424
|
+
X.save();
|
|
3425
|
+
X.fillStyle='rgba(72,140,72,0.85)';
|
|
3426
|
+
X.beginPath();
|
|
3427
|
+
X.ellipse(lp.x, lp.y, 5*lsz, 3.2*lsz, 0, -0.4, Math.PI*2-0.4);
|
|
3428
|
+
X.lineTo(lp.x, lp.y);
|
|
3429
|
+
X.closePath(); X.fill();
|
|
3430
|
+
// notch (darker wedge) — small triangle cut-out
|
|
3431
|
+
X.fillStyle='rgba(30,90,40,0.7)';
|
|
3432
|
+
X.beginPath();
|
|
3433
|
+
X.moveTo(lp.x, lp.y);
|
|
3434
|
+
X.lineTo(lp.x+5.2*lsz, lp.y-0.6*lsz);
|
|
3435
|
+
X.lineTo(lp.x+5.2*lsz, lp.y+0.6*lsz);
|
|
3436
|
+
X.closePath(); X.fill();
|
|
3437
|
+
// highlight
|
|
3438
|
+
X.fillStyle='rgba(150,210,130,0.45)';
|
|
3439
|
+
X.beginPath(); X.ellipse(lp.x-1.2*lsz, lp.y-0.8*lsz, 2*lsz, 0.8*lsz, 0, 0, Math.PI*2); X.fill();
|
|
3440
|
+
// tiny flower on one of them
|
|
3441
|
+
if(li===1){
|
|
3442
|
+
X.fillStyle='rgba(252,220,232,0.95)';
|
|
3443
|
+
X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 1.2*lsz, 0, Math.PI*2); X.fill();
|
|
3444
|
+
X.fillStyle='rgba(248,184,200,0.95)';
|
|
3445
|
+
X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 0.6*lsz, 0, Math.PI*2); X.fill();
|
|
3446
|
+
}
|
|
3447
|
+
X.restore();
|
|
3448
|
+
}
|
|
3449
|
+
// ── tiny fountain — 3 concentric rings of decreasing opacity around lake centroid ──
|
|
3450
|
+
X.save();
|
|
3451
|
+
for(var fk=0;fk<3;fk++){
|
|
3452
|
+
var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(performance.now()/600 + fk));
|
|
3453
|
+
var falpha=0.55 - fk*0.18;
|
|
3454
|
+
var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';
|
|
3455
|
+
var fringPts=[];
|
|
3456
|
+
for(var fj=0;fj<Math.PI*2;fj+=Math.PI/14){
|
|
3457
|
+
fringPts.push([POND.cx + fr*Math.cos(fj), POND.cy + fr*Math.sin(fj)*0.7]);
|
|
3458
|
+
}
|
|
3459
|
+
poly(fringPts, null, fcol, 1.0*z);
|
|
3460
|
+
}
|
|
3461
|
+
X.restore();
|
|
3462
|
+
// ── fully styled walking path — from plaza bottom edge curving right through the grass ──
|
|
3463
|
+
X.lineCap='round'; X.lineJoin='round';
|
|
3464
|
+
var pathPts=[
|
|
3465
|
+
[3.5, 5.7],
|
|
3466
|
+
[4.2, 6.2],
|
|
3467
|
+
[5.4, 6.5],
|
|
3468
|
+
[6.8, 6.5],
|
|
3469
|
+
[8.2, 6.2],
|
|
3470
|
+
[9.4, 5.7]
|
|
3471
|
+
];
|
|
3472
|
+
function strokePath(strokeW, strokeStyle, dashed, dash){
|
|
3473
|
+
X.beginPath();
|
|
3474
|
+
for(var spi=0;spi<pathPts.length;spi++){
|
|
3475
|
+
var spp=iso(pathPts[spi][0], pathPts[spi][1]);
|
|
3476
|
+
if(spi===0) X.moveTo(spp.x, spp.y); else X.lineTo(spp.x, spp.y);
|
|
3477
|
+
}
|
|
3478
|
+
X.lineWidth=strokeW;
|
|
3479
|
+
X.strokeStyle=strokeStyle;
|
|
3480
|
+
if(dashed){ X.setLineDash(dash); } else { X.setLineDash([]); }
|
|
3481
|
+
X.stroke();
|
|
3482
|
+
}
|
|
3483
|
+
// edge stroke (grass border) — drawn first, slightly wider
|
|
3484
|
+
strokePath(4.0*z + 1.0*z, 'rgba(140,200,160,0.55)', false, null);
|
|
3485
|
+
// base stroke — light tan compacted dirt
|
|
3486
|
+
strokePath(4.0*z, 'rgba(212,190,140,0.92)', false, null);
|
|
3487
|
+
// subtle darker speckle along the path for "compacted dirt" texture
|
|
3488
|
+
X.save();
|
|
3489
|
+
X.fillStyle='rgba(168,142,90,0.55)';
|
|
3490
|
+
for(var spt=0;spt<14;spt++){
|
|
3491
|
+
var sSeg=(spt+0.5)/14;
|
|
3492
|
+
var sIdx = Math.min(pathPts.length-2, Math.floor(sSeg*(pathPts.length-1)));
|
|
3493
|
+
var sT = sSeg*(pathPts.length-1) - sIdx;
|
|
3494
|
+
var sAx=pathPts[sIdx][0]*(1-sT)+pathPts[sIdx+1][0]*sT;
|
|
3495
|
+
var sAy=pathPts[sIdx][1]*(1-sT)+pathPts[sIdx+1][1]*sT;
|
|
3496
|
+
var sPx=iso(sAx,sAy);
|
|
3497
|
+
var sAng=Math.sin(spt*23.7)*1.4;
|
|
3498
|
+
X.beginPath(); X.arc(sPx.x+Math.cos(sAng)*1.4*z, sPx.y+Math.sin(sAng)*1.0*z, 0.5*z, 0, 7); X.fill();
|
|
3499
|
+
}
|
|
3500
|
+
X.restore();
|
|
3501
|
+
// a faint lighter highlight stripe down the middle
|
|
3502
|
+
strokePath(1.0*z, 'rgba(238,222,178,0.5)', false, null);
|
|
3503
|
+
X.setLineDash([]); X.lineCap='butt'; X.lineJoin='miter';
|
|
3504
|
+
// ── path lanterns — vertical post + warm yellow glow at the top, 3 of them ──
|
|
3505
|
+
var lanternSpots=[
|
|
3506
|
+
[pathPts[1][0], pathPts[1][1]-0.2],
|
|
3507
|
+
[pathPts[3][0], pathPts[3][1]],
|
|
3508
|
+
[pathPts[5][0], pathPts[5][1]]
|
|
3509
|
+
];
|
|
3510
|
+
for(var ln=0;ln<lanternSpots.length;ln++){
|
|
3511
|
+
var lpos=iso(lanternSpots[ln][0], lanternSpots[ln][1]);
|
|
3512
|
+
// post
|
|
3513
|
+
X.strokeStyle='rgba(36,30,28,0.92)'; X.lineWidth=1.6*z;
|
|
3514
|
+
X.beginPath(); X.moveTo(lpos.x, lpos.y); X.lineTo(lpos.x, lpos.y-1.5*z*10); X.stroke();
|
|
3515
|
+
// base plate
|
|
3516
|
+
X.fillStyle='rgba(48,40,36,0.92)';
|
|
3517
|
+
X.beginPath(); X.ellipse(lpos.x, lpos.y, 2.4*z, 0.9*z, 0, 0, Math.PI*2); X.fill();
|
|
3518
|
+
// glow at top (shadowBlur for warm glow)
|
|
3519
|
+
X.save();
|
|
3520
|
+
X.shadowColor='rgba(255,214,112,0.95)'; X.shadowBlur=12*z;
|
|
3521
|
+
X.fillStyle='rgba(255,234,140,0.95)';
|
|
3522
|
+
X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 1.8*z, 0, Math.PI*2); X.fill();
|
|
3523
|
+
X.restore();
|
|
3524
|
+
// bright core
|
|
3525
|
+
X.fillStyle='rgba(255,250,210,1)';
|
|
3526
|
+
X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 0.8*z, 0, Math.PI*2); X.fill();
|
|
3527
|
+
}
|
|
3528
|
+
// ── playground equipment in the grass area (below the plaza) ──
|
|
3529
|
+
drawPlayground(13.2, 6.4, 'rgba(224,82,52,0.92)');
|
|
3530
|
+
drawPlayground(13.8, 7.0, 'rgba(248,176,52,0.92)');
|
|
3531
|
+
drawBlueCanopy(0.9, 6.6);
|
|
1493
3532
|
}
|
|
1494
3533
|
function drawTent(wx,wy){
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
//
|
|
1503
|
-
X.
|
|
1504
|
-
X.beginPath(); X.
|
|
1505
|
-
|
|
3534
|
+
// Compact ridge tent: footprint 0.4 long x 0.28 deep (~ half a plaza tile), height 7*z.
|
|
3535
|
+
var z=cam.z, wLong=0.4, wShort=0.28, h=7*z;
|
|
3536
|
+
var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);
|
|
3537
|
+
var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);
|
|
3538
|
+
var rdL={ x:bkL.x, y:bkL.y - h };
|
|
3539
|
+
var rdR={ x:bkR.x, y:bkR.y - h };
|
|
3540
|
+
var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;
|
|
3541
|
+
// soft ground shadow
|
|
3542
|
+
X.fillStyle='rgba(0,0,0,0.30)';
|
|
3543
|
+
X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();
|
|
3544
|
+
// stone apron
|
|
3545
|
+
X.fillStyle='rgba(190,186,176,0.55)';
|
|
3546
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();
|
|
3547
|
+
X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;
|
|
3548
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();
|
|
3549
|
+
// back roof panel (shaded)
|
|
3550
|
+
X.fillStyle='rgba(196,200,212,0.97)';
|
|
3551
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();
|
|
3552
|
+
// front roof panel (lit)
|
|
3553
|
+
X.fillStyle='rgba(248,248,252,0.98)';
|
|
3554
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();
|
|
3555
|
+
// ridge beam
|
|
3556
|
+
X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.9*z;
|
|
3557
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3558
|
+
// 4 corner poles
|
|
3559
|
+
X.strokeStyle='rgba(80,60,40,0.92)'; X.lineWidth=0.5*z;
|
|
3560
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
|
|
3561
|
+
X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3562
|
+
X.strokeStyle='rgba(50,40,30,0.98)'; X.lineWidth=0.7*z;
|
|
3563
|
+
X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
|
|
3564
|
+
X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3565
|
+
// 2 mid poles on front edge
|
|
3566
|
+
var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };
|
|
3567
|
+
var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };
|
|
3568
|
+
var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };
|
|
3569
|
+
var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };
|
|
3570
|
+
X.strokeStyle='rgba(50,40,30,0.95)'; X.lineWidth=0.5*z;
|
|
3571
|
+
X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();
|
|
3572
|
+
X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();
|
|
3573
|
+
// fabric scallops on the front eave
|
|
3574
|
+
X.strokeStyle='rgba(220,224,232,0.55)'; X.lineWidth=0.4*z;
|
|
3575
|
+
function scallop(x1,y1,x2,y2,sag){
|
|
3576
|
+
var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;
|
|
3577
|
+
X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();
|
|
3578
|
+
}
|
|
3579
|
+
scallop(frL.x,frL.y, midLF.x,midLF.y, 0.8*z);
|
|
3580
|
+
scallop(midLF.x,midLF.y, midRF.x,midRF.y, 0.8*z);
|
|
3581
|
+
scallop(midRF.x,midRF.y, frR.x,frR.y, 0.8*z);
|
|
3582
|
+
// small flag at the right ridge end
|
|
3583
|
+
X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.5*z;
|
|
3584
|
+
X.beginPath(); X.moveTo(rdR.x,rdR.y); X.lineTo(rdR.x, rdR.y - 4*z); X.stroke();
|
|
3585
|
+
X.fillStyle='rgba(232,72,72,0.95)';
|
|
3586
|
+
X.beginPath(); X.moveTo(rdR.x,rdR.y-4*z); X.lineTo(rdR.x+2.5*z,rdR.y-3*z); X.lineTo(rdR.x,rdR.y-2*z); X.closePath(); X.fill();
|
|
1506
3587
|
}
|
|
1507
3588
|
function drawCar(wx,wy,col){
|
|
1508
3589
|
var p=iso(wx,wy), z=cam.z;
|
|
1509
|
-
|
|
1510
|
-
X.fillStyle=
|
|
1511
|
-
X.
|
|
3590
|
+
// soft drop shadow
|
|
3591
|
+
X.fillStyle='rgba(0,0,0,0.32)';
|
|
3592
|
+
X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();
|
|
3593
|
+
// chassis (lower body)
|
|
3594
|
+
X.fillStyle=col;
|
|
3595
|
+
X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z,26*z,11*z,3*z); X.fill();
|
|
3596
|
+
// chassis bottom shade
|
|
3597
|
+
X.fillStyle='rgba(0,0,0,0.22)';
|
|
3598
|
+
X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z,26*z,4*z,2*z); X.fill();
|
|
3599
|
+
// roof (darker top)
|
|
3600
|
+
X.fillStyle='rgba(0,0,0,0.55)';
|
|
3601
|
+
X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
|
|
3602
|
+
// roof tint with car color
|
|
3603
|
+
X.fillStyle=col;
|
|
3604
|
+
X.globalAlpha=0.6;
|
|
3605
|
+
X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
|
|
3606
|
+
X.globalAlpha=1;
|
|
3607
|
+
// windshield highlight
|
|
3608
|
+
X.fillStyle='rgba(180,220,255,0.65)';
|
|
3609
|
+
X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z,12*z,3*z,1.5*z); X.fill();
|
|
3610
|
+
// windshield shine
|
|
3611
|
+
X.fillStyle='rgba(255,255,255,0.45)';
|
|
3612
|
+
X.beginPath();
|
|
3613
|
+
X.moveTo(p.x-5*z,p.y-9*z); X.lineTo(p.x-2*z,p.y-9*z); X.lineTo(p.x-4*z,p.y-7*z); X.closePath(); X.fill();
|
|
3614
|
+
// wheels
|
|
3615
|
+
X.fillStyle='#0c0c10';
|
|
3616
|
+
X.beginPath(); X.arc(p.x-8*z,p.y+5*z,2.6*z,0,7); X.fill();
|
|
3617
|
+
X.beginPath(); X.arc(p.x+8*z,p.y+5*z,2.6*z,0,7); X.fill();
|
|
3618
|
+
X.beginPath(); X.arc(p.x-8*z,p.y+6*z,2.6*z,0,7); X.fill();
|
|
3619
|
+
X.beginPath(); X.arc(p.x+8*z,p.y+6*z,2.6*z,0,7); X.fill();
|
|
3620
|
+
// wheel rims
|
|
3621
|
+
X.fillStyle='rgba(200,200,210,0.75)';
|
|
3622
|
+
X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z,1*z,0,7); X.fill();
|
|
3623
|
+
X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z,1*z,0,7); X.fill();
|
|
3624
|
+
// headlight glow (front — right side)
|
|
3625
|
+
X.fillStyle='rgba(255,240,180,0.95)';
|
|
3626
|
+
X.beginPath(); X.arc(p.x+12*z,p.y-1*z,1.6*z,0,7); X.fill();
|
|
3627
|
+
X.fillStyle='rgba(255,240,180,0.4)';
|
|
3628
|
+
X.beginPath(); X.arc(p.x+12*z,p.y-1*z,3*z,0,7); X.fill();
|
|
3629
|
+
// tail light (rear — left side)
|
|
3630
|
+
X.fillStyle='rgba(255,80,80,0.85)';
|
|
3631
|
+
X.beginPath(); X.arc(p.x-12*z,p.y-1*z,1.2*z,0,7); X.fill();
|
|
3632
|
+
// body highlight
|
|
3633
|
+
X.fillStyle='rgba(255,255,255,0.18)';
|
|
3634
|
+
X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z,24*z,1.5*z,1*z); X.fill();
|
|
3635
|
+
}
|
|
3636
|
+
// Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.
|
|
3637
|
+
function drawPavilion(wx,wy){
|
|
3638
|
+
var p=iso(wx,wy), z=cam.z;
|
|
3639
|
+
var canopy=12*z;
|
|
3640
|
+
var poleH=6*z;
|
|
3641
|
+
// soft ground shadow under the canopy
|
|
3642
|
+
X.fillStyle='rgba(0,0,0,0.18)';
|
|
3643
|
+
X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, canopy*1.1, canopy*0.42, 0, 0, 7); X.fill();
|
|
3644
|
+
// four corner poles (slight isometric offset)
|
|
3645
|
+
var poleOff=canopy*0.42;
|
|
3646
|
+
function pole(px,py){
|
|
3647
|
+
X.strokeStyle='rgba(240,240,244,0.95)';
|
|
3648
|
+
X.lineWidth=0.9*z;
|
|
3649
|
+
X.beginPath(); X.moveTo(px, py); X.lineTo(px, py-poleH); X.stroke();
|
|
3650
|
+
// tiny base dot
|
|
3651
|
+
X.fillStyle='rgba(40,40,48,0.6)';
|
|
3652
|
+
X.beginPath(); X.arc(px, py, 0.8*z, 0, 7); X.fill();
|
|
3653
|
+
}
|
|
3654
|
+
pole(p.x-poleOff, p.y+poleOff*0.5);
|
|
3655
|
+
pole(p.x+poleOff, p.y+poleOff*0.5);
|
|
3656
|
+
pole(p.x-poleOff, p.y-poleOff*0.5);
|
|
3657
|
+
pole(p.x+poleOff, p.y-poleOff*0.5);
|
|
3658
|
+
// canopy roof — flat white square (top diamond)
|
|
3659
|
+
X.fillStyle='rgba(248,248,252,0.97)';
|
|
3660
|
+
X.beginPath();
|
|
3661
|
+
X.moveTo(p.x, p.y-poleH-canopy*0.5);
|
|
3662
|
+
X.lineTo(p.x+canopy, p.y-poleH);
|
|
3663
|
+
X.lineTo(p.x, p.y-poleH+canopy*0.5);
|
|
3664
|
+
X.lineTo(p.x-canopy, p.y-poleH);
|
|
3665
|
+
X.closePath(); X.fill();
|
|
3666
|
+
// canopy rim/edge
|
|
3667
|
+
X.strokeStyle='rgba(170,172,180,0.85)';
|
|
3668
|
+
X.lineWidth=0.8*z;
|
|
3669
|
+
X.beginPath();
|
|
3670
|
+
X.moveTo(p.x, p.y-poleH-canopy*0.5);
|
|
3671
|
+
X.lineTo(p.x+canopy, p.y-poleH);
|
|
3672
|
+
X.lineTo(p.x, p.y-poleH+canopy*0.5);
|
|
3673
|
+
X.lineTo(p.x-canopy, p.y-poleH);
|
|
3674
|
+
X.closePath(); X.stroke();
|
|
3675
|
+
// soft highlight band on the top of the canopy
|
|
3676
|
+
X.fillStyle='rgba(255,255,255,0.55)';
|
|
3677
|
+
X.beginPath();
|
|
3678
|
+
X.moveTo(p.x-canopy*0.8, p.y-poleH-canopy*0.05);
|
|
3679
|
+
X.lineTo(p.x+canopy*0.8, p.y-poleH-canopy*0.05);
|
|
3680
|
+
X.lineTo(p.x+canopy*0.6, p.y-poleH+canopy*0.25);
|
|
3681
|
+
X.lineTo(p.x-canopy*0.6, p.y-poleH+canopy*0.25);
|
|
3682
|
+
X.closePath(); X.fill();
|
|
3683
|
+
}
|
|
3684
|
+
// Abstract playground equipment: a small cluster of colored geometric shapes.
|
|
3685
|
+
function drawPlayground(wx,wy,accent){
|
|
3686
|
+
var p=iso(wx,wy), z=cam.z;
|
|
3687
|
+
// ground shadow
|
|
3688
|
+
X.fillStyle='rgba(0,0,0,0.20)';
|
|
3689
|
+
X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, 9*z, 4*z, 0, 0, 7); X.fill();
|
|
3690
|
+
// square post (red/orange)
|
|
3691
|
+
X.fillStyle=accent || 'rgba(224,82,52,0.95)';
|
|
3692
|
+
X.fillRect(p.x-3*z, p.y-8*z, 6*z, 6*z);
|
|
3693
|
+
X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z;
|
|
3694
|
+
X.strokeRect(p.x-3*z, p.y-8*z, 6*z, 6*z);
|
|
3695
|
+
// yellow circle
|
|
3696
|
+
X.fillStyle='rgba(248,212,72,0.95)';
|
|
3697
|
+
X.beginPath(); X.arc(p.x+5*z, p.y-4*z, 2.4*z, 0, Math.PI*2); X.fill();
|
|
3698
|
+
X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();
|
|
3699
|
+
// blue triangle
|
|
3700
|
+
X.fillStyle='rgba(82,142,224,0.95)';
|
|
3701
|
+
X.beginPath();
|
|
3702
|
+
X.moveTo(p.x-6*z, p.y-2*z);
|
|
3703
|
+
X.lineTo(p.x-2*z, p.y-2*z);
|
|
3704
|
+
X.lineTo(p.x-4*z, p.y-7*z);
|
|
3705
|
+
X.closePath(); X.fill();
|
|
3706
|
+
X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();
|
|
3707
|
+
// tiny base bar
|
|
3708
|
+
X.strokeStyle='rgba(48,48,52,0.85)'; X.lineWidth=1.2*z;
|
|
3709
|
+
X.beginPath(); X.moveTo(p.x-5*z, p.y); X.lineTo(p.x+6*z, p.y); X.stroke();
|
|
3710
|
+
}
|
|
3711
|
+
// Larger blue canopy (event-tent style) for the grass area.
|
|
3712
|
+
function drawBlueCanopy(wx,wy){
|
|
3713
|
+
// Compact ridge tent (slightly bigger than drawTent, blue, no flag).
|
|
3714
|
+
var z=cam.z, wLong=0.55, wShort=0.35, h=10*z;
|
|
3715
|
+
var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);
|
|
3716
|
+
var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);
|
|
3717
|
+
var rdL={ x:bkL.x, y:bkL.y - h };
|
|
3718
|
+
var rdR={ x:bkR.x, y:bkR.y - h };
|
|
3719
|
+
var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;
|
|
3720
|
+
// ground shadow
|
|
3721
|
+
X.fillStyle='rgba(0,0,0,0.32)';
|
|
3722
|
+
X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();
|
|
3723
|
+
// stone apron
|
|
3724
|
+
X.fillStyle='rgba(190,186,176,0.55)';
|
|
3725
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();
|
|
3726
|
+
X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;
|
|
3727
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();
|
|
3728
|
+
// back roof panel (shaded blue)
|
|
3729
|
+
X.fillStyle='rgba(58,104,176,0.97)';
|
|
3730
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();
|
|
3731
|
+
// front roof panel (lit blue)
|
|
3732
|
+
X.fillStyle='rgba(132,184,240,0.98)';
|
|
3733
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();
|
|
3734
|
+
// ridge beam
|
|
3735
|
+
X.strokeStyle='rgba(20,40,76,0.95)'; X.lineWidth=1.0*z;
|
|
3736
|
+
X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3737
|
+
// 4 corner poles
|
|
3738
|
+
X.strokeStyle='rgba(40,60,90,0.95)'; X.lineWidth=0.5*z;
|
|
3739
|
+
X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
|
|
3740
|
+
X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3741
|
+
X.strokeStyle='rgba(20,36,68,0.98)'; X.lineWidth=0.7*z;
|
|
3742
|
+
X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
|
|
3743
|
+
X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
|
|
3744
|
+
// 2 mid poles on front edge
|
|
3745
|
+
var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };
|
|
3746
|
+
var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };
|
|
3747
|
+
var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };
|
|
3748
|
+
var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };
|
|
3749
|
+
X.strokeStyle='rgba(20,36,68,0.95)'; X.lineWidth=0.5*z;
|
|
3750
|
+
X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();
|
|
3751
|
+
X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();
|
|
3752
|
+
// fabric scallops on the front eave
|
|
3753
|
+
X.strokeStyle='rgba(220,232,248,0.6)'; X.lineWidth=0.4*z;
|
|
3754
|
+
function scallop(x1,y1,x2,y2,sag){
|
|
3755
|
+
var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;
|
|
3756
|
+
X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();
|
|
3757
|
+
}
|
|
3758
|
+
scallop(frL.x,frL.y, midLF.x,midLF.y, 1.0*z);
|
|
3759
|
+
scallop(midLF.x,midLF.y, midRF.x,midRF.y, 1.0*z);
|
|
3760
|
+
scallop(midRF.x,midRF.y, frR.x,frR.y, 1.0*z);
|
|
1512
3761
|
}
|
|
1513
3762
|
function drawPalm(wx,wy){
|
|
1514
3763
|
var p=iso(wx,wy), z=cam.z;
|
|
1515
|
-
|
|
1516
|
-
X.
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
3764
|
+
// soft ground shadow
|
|
3765
|
+
X.fillStyle='rgba(0,0,0,0.30)';
|
|
3766
|
+
X.beginPath(); X.ellipse(p.x,p.y+3*z,14*z,5.5*z,0,0,7); X.fill();
|
|
3767
|
+
// trunk: tapered curved column drawn as a filled quad, with a darker right side
|
|
3768
|
+
var trunkH=46*z, trunkBaseW=4*z, trunkTopW=2.2*z;
|
|
3769
|
+
// base point and apex (slight curve to the right)
|
|
3770
|
+
var baseX=p.x, baseY=p.y;
|
|
3771
|
+
var cpx=baseX+5*z, cpy=baseY-trunkH*0.5;
|
|
3772
|
+
var apexX=baseX+2*z, apexY=baseY-trunkH;
|
|
3773
|
+
// sample two intermediate points to build the curved silhouette
|
|
3774
|
+
function curveAt(t){
|
|
3775
|
+
// quadratic bezier
|
|
3776
|
+
var u=1-t;
|
|
3777
|
+
return {
|
|
3778
|
+
x:u*u*baseX + 2*u*t*cpx + t*t*apexX,
|
|
3779
|
+
y:u*u*baseY + 2*u*t*cpy + t*t*apexY
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
var sA=curveAt(0.0), sB=curveAt(0.33), sC=curveAt(0.66), sD=curveAt(1.0);
|
|
3783
|
+
// right (shaded) side of trunk — offset outward at each sample
|
|
3784
|
+
function off(px,py,t,w){
|
|
3785
|
+
// normal-ish: lean to the right
|
|
3786
|
+
return { x:px+w, y:py };
|
|
3787
|
+
}
|
|
3788
|
+
var rA=off(sA.x,sA.y,0,trunkBaseW), rB=off(sB.x,sB.y,0.33,trunkBaseW*0.78);
|
|
3789
|
+
var rC=off(sC.x,sC.y,0.66,trunkBaseW*0.55), rD=off(sD.x,sD.y,1,trunkTopW);
|
|
3790
|
+
// left (lit) side
|
|
3791
|
+
function offL(px,py,t,w){ return { x:px-w, y:py }; }
|
|
3792
|
+
var lA=offL(sA.x,sA.y,0,trunkBaseW), lB=offL(sB.x,sB.y,0.33,trunkBaseW*0.78);
|
|
3793
|
+
var lC=offL(sC.x,sC.y,0.66,trunkBaseW*0.55), lD=offL(sD.x,sD.y,1,trunkTopW);
|
|
3794
|
+
// shaded right half
|
|
3795
|
+
X.fillStyle='rgba(82,52,28,0.95)';
|
|
3796
|
+
X.beginPath();
|
|
3797
|
+
X.moveTo(rA.x,rA.y);
|
|
3798
|
+
X.quadraticCurveTo(cpx+trunkBaseW, cpy, rB.x,rB.y);
|
|
3799
|
+
X.quadraticCurveTo(cpx+trunkBaseW*0.55, cpy*0.4+apexY*0.6, rC.x,rC.y);
|
|
3800
|
+
X.lineTo(rD.x,rD.y);
|
|
3801
|
+
X.lineTo(sD.x,sD.y);
|
|
3802
|
+
X.lineTo(sC.x,sC.y);
|
|
3803
|
+
X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);
|
|
3804
|
+
X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);
|
|
3805
|
+
X.closePath(); X.fill();
|
|
3806
|
+
// lighter lit left edge of trunk
|
|
3807
|
+
X.fillStyle='rgba(148,102,62,0.95)';
|
|
3808
|
+
X.beginPath();
|
|
3809
|
+
X.moveTo(lA.x,lA.y);
|
|
3810
|
+
X.quadraticCurveTo(cpx-trunkBaseW, cpy, lB.x,lB.y);
|
|
3811
|
+
X.quadraticCurveTo(cpx-trunkBaseW*0.55, cpy*0.4+apexY*0.6, lC.x,lC.y);
|
|
3812
|
+
X.lineTo(sC.x,sC.y);
|
|
3813
|
+
X.lineTo(sB.x,sB.y);
|
|
3814
|
+
X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);
|
|
3815
|
+
X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);
|
|
3816
|
+
X.closePath(); X.fill();
|
|
3817
|
+
// trunk segment rings (texture)
|
|
3818
|
+
X.strokeStyle='rgba(48,28,14,0.55)'; X.lineWidth=0.8*z;
|
|
3819
|
+
for(var ri=1;ri<6;ri++){
|
|
3820
|
+
var tr=ri/6;
|
|
3821
|
+
var tl=curveAt(tr-0.04), trr=curveAt(tr+0.04);
|
|
3822
|
+
var tlW=(trunkBaseW*(1-tr)+trunkTopW*tr);
|
|
3823
|
+
X.beginPath();
|
|
3824
|
+
X.moveTo(tl.x-tlW,tl.y);
|
|
3825
|
+
X.lineTo(tl.x+tlW,tl.y);
|
|
3826
|
+
X.stroke();
|
|
3827
|
+
}
|
|
3828
|
+
// crown center
|
|
3829
|
+
var tx=apexX, ty=apexY;
|
|
3830
|
+
// coconuts near the crown
|
|
3831
|
+
X.fillStyle='rgba(60,38,20,0.95)';
|
|
3832
|
+
X.beginPath(); X.arc(tx-3*z,ty+2*z,2*z,0,7); X.fill();
|
|
3833
|
+
X.beginPath(); X.arc(tx+3.5*z,ty+2.5*z,2.1*z,0,7); X.fill();
|
|
3834
|
+
X.beginPath(); X.arc(tx+0.5*z,ty+4*z,1.8*z,0,7); X.fill();
|
|
3835
|
+
// coconut highlights
|
|
3836
|
+
X.fillStyle='rgba(180,140,90,0.6)';
|
|
3837
|
+
X.beginPath(); X.arc(tx-3.6*z,ty+1.4*z,0.7*z,0,7); X.fill();
|
|
3838
|
+
X.beginPath(); X.arc(tx+2.9*z,ty+1.9*z,0.7*z,0,7); X.fill();
|
|
3839
|
+
// frond layer 1 — drooping down (8 fronds)
|
|
3840
|
+
var frondCount=8;
|
|
3841
|
+
for(var a=0;a<frondCount;a++){
|
|
3842
|
+
var ang=(-Math.PI/2) + (a-(frondCount-1)/2)*(Math.PI*1.05/frondCount);
|
|
3843
|
+
// drooping fronds have a downward bias
|
|
3844
|
+
var droop=0.45;
|
|
3845
|
+
var tipDx=Math.cos(ang)*22*z;
|
|
3846
|
+
var tipDy=Math.sin(ang)*14*z + 6*z; // pull down
|
|
3847
|
+
var ctlDx=Math.cos(ang)*8*z + Math.sin(ang)*4*z;
|
|
3848
|
+
var ctlDy=Math.sin(ang)*4*z + 8*z;
|
|
3849
|
+
// frond gradient from dark base to light tip
|
|
3850
|
+
var fGrad=X.createLinearGradient(tx+ctlDx*0.2, ty+ctlDy*0.2, tx+tipDx, ty+tipDy);
|
|
3851
|
+
fGrad.addColorStop(0,'rgba(34,90,46,0.95)');
|
|
3852
|
+
fGrad.addColorStop(0.6,'rgba(60,140,68,0.92)');
|
|
3853
|
+
fGrad.addColorStop(1,'rgba(130,200,110,0.88)');
|
|
3854
|
+
X.fillStyle=fGrad;
|
|
3855
|
+
X.beginPath();
|
|
3856
|
+
X.moveTo(tx,ty);
|
|
3857
|
+
X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);
|
|
3858
|
+
// tip point then back via a second control point that bows out
|
|
3859
|
+
var backCtlX=tx+tipDx*0.7 - Math.sin(ang)*3*z;
|
|
3860
|
+
var backCtlY=ty+tipDy*0.7 - Math.cos(ang)*2*z + 1*z;
|
|
3861
|
+
X.quadraticCurveTo(backCtlX, backCtlY, tx, ty);
|
|
3862
|
+
X.closePath(); X.fill();
|
|
3863
|
+
// frond center vein
|
|
3864
|
+
X.strokeStyle='rgba(28,70,36,0.6)'; X.lineWidth=0.7*z;
|
|
3865
|
+
X.beginPath();
|
|
3866
|
+
X.moveTo(tx,ty);
|
|
3867
|
+
X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);
|
|
3868
|
+
X.stroke();
|
|
3869
|
+
}
|
|
3870
|
+
// frond layer 2 — pointing up (7 fronds, offset angles for fullness)
|
|
3871
|
+
var frondCount2=7;
|
|
3872
|
+
for(var a2=0;a2<frondCount2;a2++){
|
|
3873
|
+
var ang2=(-Math.PI/2) + (a2-(frondCount2-1)/2)*(Math.PI*0.85/frondCount2) + 0.2;
|
|
3874
|
+
var tipDx2=Math.cos(ang2)*20*z;
|
|
3875
|
+
var tipDy2=Math.sin(ang2)*16*z - 6*z; // push up
|
|
3876
|
+
var ctlDx2=Math.cos(ang2)*7*z - Math.sin(ang2)*3*z;
|
|
3877
|
+
var ctlDy2=Math.sin(ang2)*6*z - 2*z;
|
|
3878
|
+
var fGrad2=X.createLinearGradient(tx+ctlDx2*0.2, ty+ctlDy2*0.2, tx+tipDx2, ty+tipDy2);
|
|
3879
|
+
fGrad2.addColorStop(0,'rgba(40,100,52,0.95)');
|
|
3880
|
+
fGrad2.addColorStop(0.6,'rgba(70,150,76,0.92)');
|
|
3881
|
+
fGrad2.addColorStop(1,'rgba(150,210,120,0.88)');
|
|
3882
|
+
X.fillStyle=fGrad2;
|
|
3883
|
+
X.beginPath();
|
|
3884
|
+
X.moveTo(tx,ty);
|
|
3885
|
+
X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);
|
|
3886
|
+
var backCtlX2=tx+tipDx2*0.7 + Math.sin(ang2)*3*z;
|
|
3887
|
+
var backCtlY2=ty+tipDy2*0.7 + Math.cos(ang2)*1*z - 1*z;
|
|
3888
|
+
X.quadraticCurveTo(backCtlX2, backCtlY2, tx, ty);
|
|
3889
|
+
X.closePath(); X.fill();
|
|
3890
|
+
// vein
|
|
3891
|
+
X.strokeStyle='rgba(30,76,40,0.55)'; X.lineWidth=0.6*z;
|
|
3892
|
+
X.beginPath();
|
|
3893
|
+
X.moveTo(tx,ty);
|
|
3894
|
+
X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);
|
|
3895
|
+
X.stroke();
|
|
3896
|
+
}
|
|
3897
|
+
// crown core highlight
|
|
3898
|
+
X.fillStyle='rgba(200,220,160,0.45)';
|
|
3899
|
+
X.beginPath(); X.arc(tx,ty,2.5*z,0,7); X.fill();
|
|
1520
3900
|
}
|
|
1521
3901
|
function ring(cx,cy,r,frac,color){
|
|
1522
|
-
|
|
3902
|
+
// No more raised stone base plate — the plaza chessboard tile shows
|
|
3903
|
+
// through underneath. Just the soft gold glow + load arc.
|
|
3904
|
+
X.save();
|
|
3905
|
+
X.shadowColor=color; X.shadowBlur=14*cam.z;
|
|
3906
|
+
X.fillStyle='rgba(232,200,120,0.18)';
|
|
3907
|
+
X.beginPath(); X.ellipse(cx,cy, r+3*cam.z, (r+3*cam.z)*0.5, 0, 0, 7); X.fill();
|
|
3908
|
+
X.restore();
|
|
3909
|
+
X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.14)'; X.lineWidth=3*cam.z; X.stroke();
|
|
1523
3910
|
X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();
|
|
1524
3911
|
}
|
|
1525
3912
|
function label(cx,cy,txt,col,sub){
|
|
@@ -1527,44 +3914,505 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1527
3914
|
X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
|
|
1528
3915
|
X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
|
|
1529
3916
|
}
|
|
1530
|
-
function padMark(bx,by,z){
|
|
3917
|
+
function padMark(bx,by,z){
|
|
3918
|
+
// "Not joined" marker — soft crosshair on the bare plaza tile.
|
|
3919
|
+
// No more raised stone platform; the chessboard shows through.
|
|
3920
|
+
X.strokeStyle='rgba(232,200,120,0.6)'; X.lineWidth=1.4*z;
|
|
3921
|
+
X.beginPath();
|
|
3922
|
+
X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);
|
|
3923
|
+
X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);
|
|
3924
|
+
X.stroke();
|
|
3925
|
+
// soft outer glow
|
|
3926
|
+
X.strokeStyle='rgba(255,210,140,0.4)'; X.lineWidth=3*z;
|
|
3927
|
+
X.beginPath();
|
|
3928
|
+
X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);
|
|
3929
|
+
X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);
|
|
3930
|
+
X.stroke();
|
|
3931
|
+
}
|
|
3932
|
+
// Render a 3D-looking raised tile (top face + right side + front face) centered on (cx,cy)
|
|
3933
|
+
function drawTileQuad(cx,cy,w,h,fillTop,fillSide){
|
|
3934
|
+
var halfW=w*0.5, halfH=h*0.35;
|
|
3935
|
+
var lift=3*cam.z;
|
|
3936
|
+
// top face (diamond) — lit
|
|
3937
|
+
X.fillStyle=fillTop;
|
|
3938
|
+
X.beginPath();
|
|
3939
|
+
X.moveTo(cx,cy-lift-halfH);
|
|
3940
|
+
X.lineTo(cx+halfW,cy-lift);
|
|
3941
|
+
X.lineTo(cx,cy-lift+halfH);
|
|
3942
|
+
X.lineTo(cx-halfW,cy-lift);
|
|
3943
|
+
X.closePath(); X.fill();
|
|
3944
|
+
// top face bevel highlight (upper-left edges)
|
|
3945
|
+
X.strokeStyle='rgba(255,255,255,0.18)'; X.lineWidth=0.9*cam.z;
|
|
3946
|
+
X.beginPath();
|
|
3947
|
+
X.moveTo(cx,cy-lift-halfH);
|
|
3948
|
+
X.lineTo(cx-halfW,cy-lift);
|
|
3949
|
+
X.stroke();
|
|
3950
|
+
X.beginPath();
|
|
3951
|
+
X.moveTo(cx-halfW,cy-lift);
|
|
3952
|
+
X.lineTo(cx,cy-lift+halfH);
|
|
3953
|
+
X.stroke();
|
|
3954
|
+
// right side face (darker)
|
|
3955
|
+
X.fillStyle=fillSide;
|
|
3956
|
+
X.beginPath();
|
|
3957
|
+
X.moveTo(cx,cy-lift+halfH);
|
|
3958
|
+
X.lineTo(cx+halfW,cy-lift);
|
|
3959
|
+
X.lineTo(cx+halfW,cy);
|
|
3960
|
+
X.lineTo(cx,cy+halfH);
|
|
3961
|
+
X.closePath(); X.fill();
|
|
3962
|
+
// front face (darker)
|
|
3963
|
+
X.fillStyle=fillSide;
|
|
3964
|
+
X.beginPath();
|
|
3965
|
+
X.moveTo(cx-halfW,cy-lift);
|
|
3966
|
+
X.lineTo(cx,cy-lift+halfH);
|
|
3967
|
+
X.lineTo(cx,cy+halfH);
|
|
3968
|
+
X.lineTo(cx-halfW,cy);
|
|
3969
|
+
X.closePath(); X.fill();
|
|
3970
|
+
// outline
|
|
3971
|
+
X.strokeStyle='rgba(0,0,0,0.45)'; X.lineWidth=0.8*cam.z;
|
|
3972
|
+
X.beginPath();
|
|
3973
|
+
X.moveTo(cx,cy-lift-halfH); X.lineTo(cx+halfW,cy-lift);
|
|
3974
|
+
X.lineTo(cx+halfW,cy); X.lineTo(cx,cy+halfH);
|
|
3975
|
+
X.lineTo(cx-halfW,cy); X.lineTo(cx-halfW,cy-lift);
|
|
3976
|
+
X.closePath(); X.stroke();
|
|
3977
|
+
}
|
|
1531
3978
|
function drawHub(n){
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
3979
|
+
// Desktop PC / server workstation: a tower on the left, a monitor on the right,
|
|
3980
|
+
// a keyboard in front and a mouse to the right. Centered on (n.wx, n.wy) on the iso ground.
|
|
3981
|
+
var z=cam.z;
|
|
3982
|
+
var bob=Math.sin((performance.now()-t0)/700)*1.2*z;
|
|
3983
|
+
// base point on the iso ground (the workstation sits centered here, slightly raised by bob)
|
|
3984
|
+
var p=iso(n.wx, n.wy);
|
|
3985
|
+
var gx=p.x, gy=p.y - bob; // ground reference
|
|
3986
|
+
// soft floor shadow under the whole workstation
|
|
3987
|
+
X.fillStyle='rgba(0,0,0,0.32)';
|
|
3988
|
+
X.beginPath(); X.ellipse(gx, gy+3*z, 32*z, 12*z, 0, 0, 7); X.fill();
|
|
3989
|
+
// (desk-mat removed — the bare plaza tile now shows under the workstation)
|
|
3990
|
+
|
|
3991
|
+
// ── PC TOWER (left of center, vertical box) ──
|
|
3992
|
+
// tower footprint ~14*z wide x 10*z deep on the desk, height 26*z
|
|
3993
|
+
var tx=gx-18*z, ty=gy-11*z; // tower base x, y on desk top
|
|
3994
|
+
var tw=14*z, td=10*z, th=26*z;
|
|
3995
|
+
// right side face (darker)
|
|
3996
|
+
X.fillStyle='rgba(36,32,48,0.97)';
|
|
3997
|
+
X.beginPath();
|
|
3998
|
+
X.moveTo(tx+tw, ty);
|
|
3999
|
+
X.lineTo(tx+tw, ty-th);
|
|
4000
|
+
X.lineTo(tx+tw+td*0.45, ty-th+td*0.35);
|
|
4001
|
+
X.lineTo(tx+tw+td*0.45, ty+td*0.35);
|
|
4002
|
+
X.closePath(); X.fill();
|
|
4003
|
+
// front face (lit)
|
|
4004
|
+
X.fillStyle='rgba(56,50,72,0.97)';
|
|
4005
|
+
X.beginPath();
|
|
4006
|
+
X.moveTo(tx, ty);
|
|
4007
|
+
X.lineTo(tx, ty-th);
|
|
4008
|
+
X.lineTo(tx+tw, ty-th);
|
|
4009
|
+
X.lineTo(tx+tw, ty);
|
|
4010
|
+
X.closePath(); X.fill();
|
|
4011
|
+
// top face
|
|
4012
|
+
X.fillStyle='rgba(78,70,96,0.97)';
|
|
4013
|
+
X.beginPath();
|
|
4014
|
+
X.moveTo(tx, ty);
|
|
4015
|
+
X.lineTo(tx+tw, ty);
|
|
4016
|
+
X.lineTo(tx+tw+td*0.45, ty+td*0.35);
|
|
4017
|
+
X.lineTo(tx+td*0.45, ty+td*0.35);
|
|
4018
|
+
X.closePath(); X.fill();
|
|
4019
|
+
// tower outline
|
|
4020
|
+
X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;
|
|
4021
|
+
X.beginPath();
|
|
4022
|
+
X.moveTo(tx, ty); X.lineTo(tx, ty-th); X.lineTo(tx+tw, ty-th); X.lineTo(tx+tw, ty); X.closePath(); X.stroke();
|
|
4023
|
+
X.beginPath();
|
|
4024
|
+
X.moveTo(tx, ty); X.lineTo(tx+tw, ty); X.lineTo(tx+tw+td*0.45, ty+td*0.35); X.lineTo(tx+td*0.45, ty+td*0.35); X.closePath(); X.stroke();
|
|
4025
|
+
X.beginPath();
|
|
4026
|
+
X.moveTo(tx, ty-th); X.lineTo(tx+td*0.45, ty-th+td*0.35); X.stroke();
|
|
4027
|
+
X.beginPath();
|
|
4028
|
+
X.moveTo(tx+tw, ty-th); X.lineTo(tx+tw+td*0.45, ty-th+td*0.35); X.stroke();
|
|
4029
|
+
// front-face details: power button + drive bay slots + LED
|
|
4030
|
+
X.fillStyle='rgba(255,214,112,0.95)';
|
|
4031
|
+
X.beginPath(); X.arc(tx+3*z, ty-4*z, 1.4*z, 0, 7); X.fill(); // power LED
|
|
4032
|
+
// drive bay slots
|
|
4033
|
+
X.fillStyle='rgba(20,16,28,0.95)';
|
|
4034
|
+
X.fillRect(tx+2*z, ty-9*z, 10*z, 2*z);
|
|
4035
|
+
X.fillRect(tx+2*z, ty-13*z, 10*z, 2*z);
|
|
4036
|
+
X.fillRect(tx+2*z, ty-17*z, 10*z, 2*z);
|
|
4037
|
+
// blinking activity LED (green)
|
|
4038
|
+
var blink=Math.abs(Math.sin((performance.now()-t0)/300));
|
|
4039
|
+
X.fillStyle='rgba(110,224,166,'+(0.5+0.5*blink).toFixed(3)+')';
|
|
4040
|
+
X.beginPath(); X.arc(tx+11*z, ty-4*z, 0.9*z, 0, 7); X.fill();
|
|
4041
|
+
// mesh vent lines on right side (darker grooves)
|
|
4042
|
+
X.strokeStyle='rgba(12,8,18,0.7)'; X.lineWidth=0.5*z;
|
|
4043
|
+
for(var vi=0;vi<5;vi++){ X.beginPath(); X.moveTo(tx+tw+0.6*z, ty-2*z-vi*4*z); X.lineTo(tx+tw+td*0.45-0.6*z, ty-2*z-vi*4*z+td*0.35); X.stroke(); }
|
|
4044
|
+
|
|
4045
|
+
// ── MONITOR (right of center) ──
|
|
4046
|
+
// stand base, neck, screen
|
|
4047
|
+
var mx=gx+4*z, my=gy-11*z; // monitor base on desk
|
|
4048
|
+
var mw=18*z, mh=14*z; // screen width / height
|
|
4049
|
+
// monitor neck
|
|
4050
|
+
X.fillStyle='rgba(50,44,68,0.95)';
|
|
4051
|
+
X.beginPath(); X.moveTo(mx+5*z, my); X.lineTo(mx+13*z, my); X.lineTo(mx+12*z, my-3*z); X.lineTo(mx+6*z, my-3*z); X.closePath(); X.fill();
|
|
4052
|
+
// monitor stand base (small ellipse)
|
|
4053
|
+
X.fillStyle='rgba(40,34,52,0.95)';
|
|
4054
|
+
X.beginPath(); X.ellipse(mx+9*z, my+0.5*z, 6*z, 1.4*z, 0, 0, 7); X.fill();
|
|
4055
|
+
// monitor screen (lit, animated content)
|
|
4056
|
+
var scrX=mx-2*z, scrY=my-3*z - mh;
|
|
4057
|
+
// screen bezel
|
|
4058
|
+
X.fillStyle='rgba(20,16,28,0.98)';
|
|
4059
|
+
X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.fill();
|
|
4060
|
+
// screen surface — animated dashboard gradient
|
|
4061
|
+
var scrGrad=X.createLinearGradient(scrX, scrY, scrX, scrY+mh);
|
|
4062
|
+
scrGrad.addColorStop(0, n.connected?'rgba(36,76,108,0.95)':'rgba(48,30,30,0.95)');
|
|
4063
|
+
scrGrad.addColorStop(1, n.connected?'rgba(20,40,64,0.95)':'rgba(28,18,18,0.95)');
|
|
4064
|
+
X.fillStyle=scrGrad;
|
|
4065
|
+
X.beginPath(); X.roundRect(scrX+1*z, scrY+1*z, mw-2*z, mh-3*z, 1*z); X.fill();
|
|
4066
|
+
// screen content — animated bars (looks like a live dashboard)
|
|
4067
|
+
X.fillStyle='rgba(110,224,166,0.85)';
|
|
4068
|
+
for(var bi=0;bi<5;bi++){
|
|
4069
|
+
var bw=2.5*z, bh=(3 + 4*Math.abs(Math.sin((performance.now()-t0)/400 + bi*0.7)))*z;
|
|
4070
|
+
X.fillRect(scrX+2.5*z + bi*3*z, scrY+mh-3*z-bh, bw, bh);
|
|
4071
|
+
}
|
|
4072
|
+
// header bar (gold)
|
|
4073
|
+
X.fillStyle='rgba(255,214,112,0.7)';
|
|
4074
|
+
X.fillRect(scrX+1.5*z, scrY+2*z, mw-3*z, 1*z);
|
|
4075
|
+
// tiny "REC" dot
|
|
4076
|
+
X.fillStyle='rgba(255,90,90,0.95)';
|
|
4077
|
+
X.beginPath(); X.arc(scrX+3*z, scrY+4*z, 0.7*z, 0, 7); X.fill();
|
|
4078
|
+
// monitor bezel outline
|
|
4079
|
+
X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;
|
|
4080
|
+
X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.stroke();
|
|
4081
|
+
// brand bar at bottom of monitor
|
|
4082
|
+
X.fillStyle='rgba(255,214,112,0.85)';
|
|
4083
|
+
X.fillRect(scrX+mw*0.35, scrY+mh-1.4*z, mw*0.3, 0.6*z);
|
|
4084
|
+
|
|
4085
|
+
// ── KEYBOARD (in front of the monitor) ──
|
|
4086
|
+
var kbX=mx-1*z, kbY=gy-2*z;
|
|
4087
|
+
var kbW=18*z, kbH=4.5*z;
|
|
4088
|
+
X.fillStyle='rgba(28,24,36,0.95)';
|
|
4089
|
+
X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.fill();
|
|
4090
|
+
X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;
|
|
4091
|
+
X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.stroke();
|
|
4092
|
+
// key rows
|
|
4093
|
+
X.fillStyle='rgba(70,62,86,0.95)';
|
|
4094
|
+
for(var kr=0;kr<3;kr++){
|
|
4095
|
+
for(var kc=0;kc<10;kc++){
|
|
4096
|
+
X.fillRect(kbX+1*z + kc*1.7*z, kbY+0.5*z + kr*1.3*z, 1.2*z, 1*z);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
// spacebar
|
|
4100
|
+
X.fillRect(kbX+5*z, kbY+0.5*z+3*1.3*z, 6*z, 1*z);
|
|
4101
|
+
|
|
4102
|
+
// ── MOUSE (right of keyboard) ──
|
|
4103
|
+
var msX=kbX+kbW+1.5*z, msY=kbY+0.5*z;
|
|
4104
|
+
X.fillStyle='rgba(28,24,36,0.95)';
|
|
4105
|
+
X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.fill();
|
|
4106
|
+
X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;
|
|
4107
|
+
X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.stroke();
|
|
4108
|
+
// scroll wheel
|
|
4109
|
+
X.fillStyle='rgba(255,214,112,0.85)';
|
|
4110
|
+
X.fillRect(msX+1.4*z, msY+0.5*z, 0.3*z, 0.8*z);
|
|
4111
|
+
|
|
4112
|
+
// ── glowing signal lines coming from the monitor (radio waves) ──
|
|
4113
|
+
for(var ri=0;ri<3;ri++){
|
|
4114
|
+
var rphase=((performance.now()-t0)/1100 + ri*0.33) % 1;
|
|
4115
|
+
var rr=10*z + rphase*22*z;
|
|
4116
|
+
var ralpha=(1-rphase)*0.5;
|
|
4117
|
+
X.strokeStyle='rgba(255,214,112,'+ralpha.toFixed(3)+')';
|
|
4118
|
+
X.lineWidth=0.8*z;
|
|
4119
|
+
X.beginPath();
|
|
4120
|
+
X.arc(scrX+mw*0.5, scrY+mh*0.5, rr, Math.PI*0.85, Math.PI*1.15);
|
|
4121
|
+
X.stroke();
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
// ── label below the workstation ──
|
|
4125
|
+
var labelX=gx;
|
|
4126
|
+
var labelY=gy+10*z;
|
|
4127
|
+
label(labelX, labelY, n.name, getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
|
|
4128
|
+
// hit-box covers the whole workstation footprint
|
|
4129
|
+
n._hit={x:gx-32*z, y:scrY-3*z, w:64*z, h:(labelY+8*z)-(scrY-3*z)};
|
|
1541
4130
|
}
|
|
1542
4131
|
function drawRobot(n){
|
|
1543
|
-
var p=iso(n.wx,n.wy), z=cam.z,
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
var
|
|
1547
|
-
|
|
4132
|
+
var p=iso(n.wx,n.wy), z=cam.z, now=performance.now();
|
|
4133
|
+
// Slight bob varies by status: live/talking robots get a livelier
|
|
4134
|
+
// bob, offline/ended ones stay still.
|
|
4135
|
+
var bobAmp = (n.status==='live'||n.status==='talking') ? 2.6*z
|
|
4136
|
+
: (n.status==='ended') ? 0.4*z
|
|
4137
|
+
: (n.connected ? 1.6*z : 0);
|
|
4138
|
+
var bob = bobAmp ? Math.sin(now/500 + (n.wx||0)) * bobAmp : 0;
|
|
4139
|
+
var bx=p.x, by=p.y-4*z-bob;
|
|
4140
|
+
var status = n.status || (n.connected ? 'ready' : 'offline');
|
|
4141
|
+
|
|
4142
|
+
// ── status-driven ground effect (drawn under the avatar) ──
|
|
4143
|
+
// Each status has a distinct footprint so you can read what's
|
|
4144
|
+
// happening from across the plaza: a green pulse for live, an
|
|
4145
|
+
// expanding sound ring for talking, a slow blue breathing
|
|
4146
|
+
// halo for ready/waiting, a fading grey ring for ended, and
|
|
4147
|
+
// a dim grey halo for offline.
|
|
4148
|
+
drawStatusGround(n, status, bx, p.y+3*z, z, now);
|
|
4149
|
+
|
|
4150
|
+
// Shadow under the avatar (kept subtle so the new ground effect
|
|
4151
|
+
// is the visual focus).
|
|
4152
|
+
X.fillStyle='rgba(0,0,0,0.28)';
|
|
4153
|
+
X.beginPath(); X.ellipse(bx, p.y+3*z, 13*z, 5.5*z, 0, 0, Math.PI*2); X.fill();
|
|
4154
|
+
|
|
4155
|
+
// Status-colored rim under the avatar (thin ring, same hue as
|
|
4156
|
+
// the status ground effect — binds the avatar to its footprint).
|
|
4157
|
+
var rimColor = statusRimColor(status);
|
|
4158
|
+
X.strokeStyle=rimColor; X.lineWidth=1.4*z;
|
|
4159
|
+
X.beginPath(); X.ellipse(bx, p.y+3*z, 18*z, 8*z, 0, 0, Math.PI*2); X.stroke();
|
|
4160
|
+
|
|
4161
|
+
// Load arc (still useful — shows CPU pressure even on a live
|
|
4162
|
+
// robot). Drawn at a slightly larger radius than the status rim
|
|
4163
|
+
// so the two don't fight visually.
|
|
4164
|
+
if(n.connected) ring(bx, p.y+3*z, 18*z, n.load/100, loadColor(n.load));
|
|
4165
|
+
else padMark(bx, p.y+3*z, z);
|
|
4166
|
+
|
|
4167
|
+
var col = n.connected ? rimColor : 'rgba(150,143,120,0.5)';
|
|
4168
|
+
X.globalAlpha = n.connected ? 1 : 0.7;
|
|
1548
4169
|
var img = avatarImageForNode(n);
|
|
1549
4170
|
if(img && img.complete && img.naturalWidth>0){
|
|
1550
|
-
var iw=
|
|
4171
|
+
var iw=40*z, ih=40*z*img.naturalHeight/img.naturalWidth;
|
|
1551
4172
|
X.save();
|
|
1552
|
-
X.beginPath(); X.roundRect(bx-
|
|
1553
|
-
X.drawImage(img, bx-
|
|
4173
|
+
X.beginPath(); X.roundRect(bx-20*z, by-54*z, iw, ih+12*z, 8*z); X.clip();
|
|
4174
|
+
X.drawImage(img, bx-20*z, by-60*z, iw, ih+18*z);
|
|
1554
4175
|
X.restore();
|
|
1555
|
-
X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();
|
|
1556
4176
|
} else {
|
|
1557
|
-
X.fillStyle='rgba(24,20,38,0.92)';
|
|
1558
|
-
X.beginPath(); X.roundRect(bx-
|
|
1559
|
-
X.
|
|
1560
|
-
X.
|
|
1561
|
-
|
|
1562
|
-
|
|
4177
|
+
X.fillStyle='rgba(24,20,38,0.92)';
|
|
4178
|
+
X.beginPath(); X.roundRect(bx-17*z, by-36*z, 34*z, 36*z, 7*z); X.fill();
|
|
4179
|
+
X.fillStyle='rgba(30,24,44,0.95)';
|
|
4180
|
+
X.beginPath(); X.roundRect(bx-12*z, by-54*z, 24*z, 20*z, 5*z); X.fill();
|
|
4181
|
+
// Mouth/eyes default to the live color; "talking" status will
|
|
4182
|
+
// animate the mouth in drawStatusOverlays() below.
|
|
4183
|
+
X.fillStyle = n.connected ? getVar('--ok') : '#655';
|
|
4184
|
+
X.beginPath(); X.arc(bx-5*z, by-44*z, 3*z, 0, 7); X.arc(bx+5*z, by-44*z, 3*z, 0, 7); X.fill();
|
|
4185
|
+
// mouth dot — gets animated when status==='talking'
|
|
4186
|
+
X.fillStyle = (status==='talking') ? '#FF5B4A' : ((status==='ended') ? 'rgba(200,180,160,0.5)' : '#FF5B4A');
|
|
4187
|
+
X.beginPath(); X.arc(bx, by-65*z, (status==='talking' ? 2.2 + 1.4*Math.abs(Math.sin(now/85)) : 2.4)*z, 0, 7); X.fill();
|
|
1563
4188
|
}
|
|
1564
4189
|
X.globalAlpha=1;
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
4190
|
+
|
|
4191
|
+
// status overlays: speech rings (talking), thought bubble
|
|
4192
|
+
// (waiting/ready), a "👋 bye" mark (ended). Drawn ABOVE the
|
|
4193
|
+
// avatar so they read at a glance from the rest of the plaza.
|
|
4194
|
+
drawStatusOverlays(n, status, bx, by, z, now);
|
|
4195
|
+
|
|
4196
|
+
// sub-text under the avatar — show readiness + a short status word
|
|
4197
|
+
var subBits = [];
|
|
4198
|
+
if(n.sub && n.sub!==n.name) subBits.push(n.sub);
|
|
4199
|
+
if(n.connected){
|
|
4200
|
+
if(status==='talking') subBits.push('talking');
|
|
4201
|
+
else if(status==='live') subBits.push('live');
|
|
4202
|
+
else if(status==='ended') subBits.push('session ended');
|
|
4203
|
+
else if(status==='ready') subBits.push('waiting');
|
|
4204
|
+
subBits.push(Math.round(n.load||0)+'%');
|
|
4205
|
+
} else {
|
|
4206
|
+
subBits.push('not joined');
|
|
4207
|
+
}
|
|
4208
|
+
label(bx, p.y+17*z, n.label, col, subBits.join(' · '));
|
|
4209
|
+
// ── holographic name callout pill above the avatar (status-colored border) ──
|
|
4210
|
+
if(n.connected){
|
|
4211
|
+
var pillW=Math.max(70*z, (n.label.length*7 + 18) * z);
|
|
4212
|
+
var pillH=16*z;
|
|
4213
|
+
var pillR=8*z;
|
|
4214
|
+
var pillCx=bx;
|
|
4215
|
+
var pillCy=by - 8*z - 54*z - pillH*0.5;
|
|
4216
|
+
X.save();
|
|
4217
|
+
X.fillStyle='rgba(20,16,32,0.85)';
|
|
4218
|
+
X.beginPath();
|
|
4219
|
+
if(X.roundRect){
|
|
4220
|
+
X.roundRect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH, pillR);
|
|
4221
|
+
} else {
|
|
4222
|
+
X.rect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH);
|
|
4223
|
+
}
|
|
4224
|
+
X.fill();
|
|
4225
|
+
// pill border uses the same status hue as the ground effect
|
|
4226
|
+
X.strokeStyle = rimColor; X.lineWidth=1.0*z;
|
|
4227
|
+
X.stroke();
|
|
4228
|
+
X.fillStyle='rgba(255,232,160,0.95)';
|
|
4229
|
+
X.font='700 '+(10*z)+'px '+mono();
|
|
4230
|
+
X.textAlign='center'; X.textBaseline='middle';
|
|
4231
|
+
X.fillText(n.label, pillCx, pillCy);
|
|
4232
|
+
X.restore();
|
|
4233
|
+
// ── pulsing status dot to the right of the pill (faster pulse for live, fastest for talking) ──
|
|
4234
|
+
var dotCx=pillCx + pillW*0.5 + 4*z;
|
|
4235
|
+
var dotCy=pillCy;
|
|
4236
|
+
var dotPeriod = status==='talking' ? 110 : (status==='live' ? 230 : 400);
|
|
4237
|
+
var dotAlpha=0.55+0.45*Math.abs(Math.sin(now/dotPeriod + (n.wx||0)));
|
|
4238
|
+
X.save();
|
|
4239
|
+
X.fillStyle=hexWithAlpha(rimColor, dotAlpha.toFixed(3));
|
|
4240
|
+
X.shadowColor=rimColor; X.shadowBlur=6*z;
|
|
4241
|
+
X.beginPath(); X.arc(dotCx, dotCy, 3*z, 0, Math.PI*2); X.fill();
|
|
4242
|
+
X.restore();
|
|
4243
|
+
}
|
|
4244
|
+
n._hit={x:bx-25*z,y:by-67*z,w:50*z,h:80*z};
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
// Map status → rgb base color (used for ground effect, rim, dot).
|
|
4248
|
+
// Kept in a small helper so all the status-driven effects stay in
|
|
4249
|
+
// sync visually.
|
|
4250
|
+
function statusRimColor(status){
|
|
4251
|
+
switch(status){
|
|
4252
|
+
case 'talking': return 'rgb(110,224,166)'; // bright green — voice is active
|
|
4253
|
+
case 'live': return 'rgb(255,214,112)'; // gold — session open, idle
|
|
4254
|
+
case 'ended': return 'rgb(180,170,160)'; // grey — session just ended, fading
|
|
4255
|
+
case 'ready': return 'rgb(110,180,255)'; // soft blue — waiting/ready
|
|
4256
|
+
case 'offline': return 'rgba(150,143,120,0.5)';
|
|
4257
|
+
default: return 'rgb(255,214,112)';
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
// Build an rgba() string from an rgb() base + an alpha. Used so
|
|
4261
|
+
// shadowColor / strokeStyle can blend nicely against the gold grid.
|
|
4262
|
+
function hexWithAlpha(rgb, a){
|
|
4263
|
+
// Accepts "rgb(r,g,b)" or "rgba(r,g,b,x)"; returns rgba(r,g,b,a).
|
|
4264
|
+
// This file is embedded in a TypeScript template string, so regex
|
|
4265
|
+
// backslashes must be doubled or the browser receives an invalid regex.
|
|
4266
|
+
var m = /rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/.exec(rgb);
|
|
4267
|
+
if(!m) return rgb;
|
|
4268
|
+
return 'rgba('+m[1]+','+m[2]+','+m[3]+','+a+')';
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
// ── status-driven ground effect under the avatar ──
|
|
4272
|
+
// Drawn before the avatar so the rim + ring sit on top. Each
|
|
4273
|
+
// status reads as a distinct footprint color + animation so the
|
|
4274
|
+
// plaza instantly tells you which robots are quiet, waiting,
|
|
4275
|
+
// listening, talking, or just stopped.
|
|
4276
|
+
function drawStatusGround(n, status, cx, cy, z, now){
|
|
4277
|
+
if(status === 'offline'){
|
|
4278
|
+
// dim grey aura (kept from the original)
|
|
4279
|
+
var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, 22*z);
|
|
4280
|
+
g.addColorStop(0,'rgba(140,140,160,0.16)');
|
|
4281
|
+
g.addColorStop(1,'rgba(140,140,160,0)');
|
|
4282
|
+
X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, 22*z, 11*z, 0, 0, 7); X.fill();
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
if(status === 'live'){
|
|
4286
|
+
// breathing green pulse — small inner glow that throbs every
|
|
4287
|
+
// ~0.7s, plus the load arc's gold ring already drawn above.
|
|
4288
|
+
var beat = 0.5 + 0.5*Math.sin(now/350);
|
|
4289
|
+
var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, (18 + 6*beat)*z);
|
|
4290
|
+
g.addColorStop(0, 'rgba(110,224,166,'+(0.45 + 0.25*beat).toFixed(3)+')');
|
|
4291
|
+
g.addColorStop(0.6, 'rgba(110,224,166,0.12)');
|
|
4292
|
+
g.addColorStop(1, 'rgba(110,224,166,0)');
|
|
4293
|
+
X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, (18+6*beat)*z, (9+3*beat)*z, 0, 0, 7); X.fill();
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
if(status === 'talking'){
|
|
4297
|
+
// 2-3 expanding sound rings (like ⌒) — color bright green, very
|
|
4298
|
+
// distinct from the gold "live" pulse so listeners know voice
|
|
4299
|
+
// is actually flowing right now.
|
|
4300
|
+
var ringCount = 2;
|
|
4301
|
+
for(var ti=0; ti<ringCount; ti++){
|
|
4302
|
+
var tphase = ((now/420) + ti*0.5) % 1;
|
|
4303
|
+
var tr = (10 + tphase*22)*z;
|
|
4304
|
+
var ta = (1 - tphase) * 0.7;
|
|
4305
|
+
X.strokeStyle='rgba(110,224,166,'+ta.toFixed(3)+')';
|
|
4306
|
+
X.lineWidth=1.6*z;
|
|
4307
|
+
X.beginPath(); X.ellipse(cx, cy, tr, tr*0.5, 0, 0, Math.PI*2); X.stroke();
|
|
4308
|
+
}
|
|
4309
|
+
// solid inner glow so the "voice" footprint reads even when
|
|
4310
|
+
// rings are between expansions.
|
|
4311
|
+
var tg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 14*z);
|
|
4312
|
+
tg.addColorStop(0, 'rgba(110,224,166,0.45)');
|
|
4313
|
+
tg.addColorStop(1, 'rgba(110,224,166,0)');
|
|
4314
|
+
X.fillStyle=tg; X.beginPath(); X.ellipse(cx, cy, 14*z, 7*z, 0, 0, 7); X.fill();
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
if(status === 'ready'){
|
|
4318
|
+
// soft blue "waiting" halo — slow breath, calmer than live.
|
|
4319
|
+
var breath = 0.5 + 0.5*Math.sin(now/900);
|
|
4320
|
+
var rg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 20*z);
|
|
4321
|
+
rg.addColorStop(0, 'rgba(110,180,255,'+(0.18 + 0.10*breath).toFixed(3)+')');
|
|
4322
|
+
rg.addColorStop(1, 'rgba(110,180,255,0)');
|
|
4323
|
+
X.fillStyle=rg; X.beginPath(); X.ellipse(cx, cy, 20*z, 10*z, 0, 0, 7); X.fill();
|
|
4324
|
+
return;
|
|
4325
|
+
}
|
|
4326
|
+
if(status === 'ended'){
|
|
4327
|
+
// session just ended — fading grey ring expanding outward,
|
|
4328
|
+
// like a sound ring dying down. Alpha drops to 0 over 4s.
|
|
4329
|
+
var mem = _statusMem[n.id] || {};
|
|
4330
|
+
var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;
|
|
4331
|
+
if(since > 4000) return;
|
|
4332
|
+
var ft = Math.min(1, since / 4000);
|
|
4333
|
+
var fr = (16 + ft*30)*z;
|
|
4334
|
+
var fa = (1 - ft) * 0.55;
|
|
4335
|
+
X.strokeStyle='rgba(180,170,160,'+fa.toFixed(3)+')';
|
|
4336
|
+
X.lineWidth=1.4*z;
|
|
4337
|
+
X.beginPath(); X.ellipse(cx, cy, fr, fr*0.5, 0, 0, Math.PI*2); X.stroke();
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
// ── status-driven overlays above the avatar ──
|
|
4343
|
+
// Speech rings (talking), thought-bubble "?" (ready/waiting),
|
|
4344
|
+
// fading "👋" (ended). Drawn on top of the avatar so they read
|
|
4345
|
+
// at a glance.
|
|
4346
|
+
function drawStatusOverlays(n, status, bx, by, z, now){
|
|
4347
|
+
if(status === 'talking'){
|
|
4348
|
+
// Speech ring: 3 expanding arcs above the avatar, like
|
|
4349
|
+
// someone is mid-sentence. Color bright green to match the
|
|
4350
|
+
// ground pulse.
|
|
4351
|
+
for(var si=0; si<3; si++){
|
|
4352
|
+
var sphase = ((now/260) + si*0.34) % 1;
|
|
4353
|
+
var sa = (1 - sphase) * 0.85;
|
|
4354
|
+
var sr = (4 + sphase*9)*z;
|
|
4355
|
+
X.strokeStyle='rgba(110,224,166,'+sa.toFixed(3)+')';
|
|
4356
|
+
X.lineWidth=1.4*z;
|
|
4357
|
+
X.beginPath();
|
|
4358
|
+
X.arc(bx, by-58*z - sphase*4*z, sr, Math.PI*1.15, Math.PI*1.85);
|
|
4359
|
+
X.stroke();
|
|
4360
|
+
}
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
if(status === 'ready'){
|
|
4364
|
+
// Thought bubble: small cloud (3 circles) with a "?" inside.
|
|
4365
|
+
// Bobs gently so it reads as a thought, not a sticker.
|
|
4366
|
+
var bob = Math.sin(now/700) * 1.4*z;
|
|
4367
|
+
var bubbleX = bx + 20*z;
|
|
4368
|
+
var bubbleY = by - 64*z + bob;
|
|
4369
|
+
// tail circles
|
|
4370
|
+
X.fillStyle='rgba(220,234,255,0.95)';
|
|
4371
|
+
X.beginPath(); X.arc(bubbleX-7*z, bubbleY+8*z, 1.6*z, 0, Math.PI*2); X.fill();
|
|
4372
|
+
X.beginPath(); X.arc(bubbleX-4*z, bubbleY+4*z, 2.2*z, 0, Math.PI*2); X.fill();
|
|
4373
|
+
// main bubble
|
|
4374
|
+
X.beginPath(); X.arc(bubbleX, bubbleY, 6*z, 0, Math.PI*2); X.fill();
|
|
4375
|
+
X.strokeStyle='rgba(110,180,255,0.85)'; X.lineWidth=1.0*z; X.stroke();
|
|
4376
|
+
// "?" text
|
|
4377
|
+
X.fillStyle='rgba(60,90,140,0.95)';
|
|
4378
|
+
X.font='700 '+(7*z)+'px '+mono();
|
|
4379
|
+
X.textAlign='center'; X.textBaseline='middle';
|
|
4380
|
+
X.fillText('?', bubbleX, bubbleY+0.5*z);
|
|
4381
|
+
return;
|
|
4382
|
+
}
|
|
4383
|
+
if(status === 'ended'){
|
|
4384
|
+
// Fading "bye" wave — a small waving-hand glyph that rises and
|
|
4385
|
+
// fades out over 4s.
|
|
4386
|
+
var mem = _statusMem[n.id] || {};
|
|
4387
|
+
var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;
|
|
4388
|
+
if(since > 4000) return;
|
|
4389
|
+
var ft = since / 4000;
|
|
4390
|
+
var a = (1 - ft) * 0.95;
|
|
4391
|
+
var lift = ft * 14*z;
|
|
4392
|
+
X.save();
|
|
4393
|
+
X.globalAlpha = a;
|
|
4394
|
+
// small bubble background for legibility
|
|
4395
|
+
X.fillStyle='rgba(20,16,32,0.7)';
|
|
4396
|
+
X.beginPath(); X.arc(bx, by - 60*z - lift, 8*z, 0, Math.PI*2); X.fill();
|
|
4397
|
+
X.strokeStyle='rgba(180,170,160,0.9)'; X.lineWidth=0.8*z;
|
|
4398
|
+
X.stroke();
|
|
4399
|
+
// hand glyph (canvas-drawn, simpler than emoji so it always renders)
|
|
4400
|
+
var hx = bx, hy = by - 60*z - lift;
|
|
4401
|
+
X.fillStyle='rgba(255,214,112,0.95)';
|
|
4402
|
+
// palm
|
|
4403
|
+
X.beginPath(); X.arc(hx, hy + 0.5*z, 3.0*z, 0, Math.PI*2); X.fill();
|
|
4404
|
+
// 4 fingers
|
|
4405
|
+
for(var fi=-2; fi<=1; fi++){
|
|
4406
|
+
X.fillRect(hx + fi*1.2*z - 0.4*z, hy - 3.6*z, 0.8*z, 2.6*z);
|
|
4407
|
+
}
|
|
4408
|
+
// thumb
|
|
4409
|
+
X.beginPath(); X.arc(hx - 2.4*z, hy + 0.5*z, 0.9*z, 0, Math.PI*2); X.fill();
|
|
4410
|
+
// tiny side-to-side wave wobble
|
|
4411
|
+
var wobble = Math.sin(now/110) * 1.4*z;
|
|
4412
|
+
X.translate(wobble, 0);
|
|
4413
|
+
X.restore();
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
1568
4416
|
}
|
|
1569
4417
|
function drawPeer(n){
|
|
1570
4418
|
var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
|
|
@@ -1577,16 +4425,206 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1577
4425
|
n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
|
|
1578
4426
|
}
|
|
1579
4427
|
|
|
4428
|
+
// ── Walker tick + draw ──
|
|
4429
|
+
// Advances each walker along its path at a steady world-space speed
|
|
4430
|
+
// (one segment in segDurMs ms). Frame-rate independent: every
|
|
4431
|
+
// frame we move the walker by the elapsed time, not a per-tick
|
|
4432
|
+
// constant. When a walker is within greet-distance of its next
|
|
4433
|
+
// pauseAt waypoint, it pauses for 1.4s in a wave pose; the nearest
|
|
4434
|
+
// connected robot pad fires a 👋 pulse while it pauses.
|
|
4435
|
+
var _lastTickMs = 0;
|
|
4436
|
+
function tickWalkers(now){
|
|
4437
|
+
if(!_lastTickMs) _lastTickMs = now;
|
|
4438
|
+
var dt = Math.min(64, now - _lastTickMs); // clamp to avoid huge jumps on tab-switch
|
|
4439
|
+
_lastTickMs = now;
|
|
4440
|
+
for(var wi=0; wi<walkers.length; wi++){
|
|
4441
|
+
var w = walkers[wi];
|
|
4442
|
+
if(now < w.pauseUntil) continue;
|
|
4443
|
+
// Distance per frame for this walker = total path length / (segDurMs / dt) segments.
|
|
4444
|
+
// Convert to world units: distance = w.info.total * (dt / (w.path.length-1) / w.spec.segDurMs)
|
|
4445
|
+
// Simplified: dDistance = w.info.total * dt / ((w.path.length-1) * segDurMs)
|
|
4446
|
+
var stepDist = w.info.total * dt / ((w.path.length - 1) * w.spec.segDurMs);
|
|
4447
|
+
w.distance += stepDist;
|
|
4448
|
+
// Check whether we've crossed a pauseAt point during this tick.
|
|
4449
|
+
// Sample the current and previous position and see if either
|
|
4450
|
+
// got close to the next pending pauseAt waypoint. This is
|
|
4451
|
+
// cheaper than walking the whole polyline and handles fast
|
|
4452
|
+
// frame deltas.
|
|
4453
|
+
for(var pi=0; pi<w.spec.pauseAt.length; pi++){
|
|
4454
|
+
if(pi !== w.nextGreet % w.spec.pauseAt.length) continue;
|
|
4455
|
+
var pp = w.spec.pauseAt[pi];
|
|
4456
|
+
// current and previous world positions
|
|
4457
|
+
var cur = pathPosAt(w.info, w.distance);
|
|
4458
|
+
var prev = pathPosAt(w.info, w.distance - stepDist);
|
|
4459
|
+
var ca = w.path[cur.seg], cb = w.path[cur.seg+1] || w.path[0];
|
|
4460
|
+
var pa = w.path[prev.seg], pb = w.path[prev.seg+1] || w.path[0];
|
|
4461
|
+
var cwx = ca[0] + (cb[0]-ca[0])*cur.t, cwy = ca[1] + (cb[1]-ca[1])*cur.t;
|
|
4462
|
+
var pwx = pa[0] + (pb[0]-pa[0])*prev.t, pwy = pa[1] + (pb[1]-pa[1])*prev.t;
|
|
4463
|
+
// Closest distance from the line segment prev→cur to the pause point.
|
|
4464
|
+
var dx = cwx - pwx, dy = cwy - pwy;
|
|
4465
|
+
var segLen2 = dx*dx + dy*dy;
|
|
4466
|
+
var t = 0;
|
|
4467
|
+
if(segLen2 > 1e-6){
|
|
4468
|
+
t = ((pp[0]-pwx)*dx + (pp[1]-pwy)*dy) / segLen2;
|
|
4469
|
+
if(t < 0) t = 0; else if(t > 1) t = 1;
|
|
4470
|
+
}
|
|
4471
|
+
var qx = pwx + dx*t, qy = pwy + dy*t;
|
|
4472
|
+
var ddx = qx - pp[0], ddy = qy - pp[1];
|
|
4473
|
+
if(ddx*ddx + ddy*ddy < 0.35*0.35){
|
|
4474
|
+
w.pauseUntil = now + 1400;
|
|
4475
|
+
w.nextGreet++;
|
|
4476
|
+
// pulse the nearest connected robot pad so the interaction is visible
|
|
4477
|
+
var best = null, bd = 4.0;
|
|
4478
|
+
for(var ni=0; ni<nodes.length; ni++){
|
|
4479
|
+
var nd = nodes[ni];
|
|
4480
|
+
if(nd.kind !== 'robot' || !nd.connected) continue;
|
|
4481
|
+
var nx = nd.wx - pp[0], ny = nd.wy - pp[1];
|
|
4482
|
+
var dd = nx*nx + ny*ny;
|
|
4483
|
+
if(dd < bd){ bd = dd; best = nd; }
|
|
4484
|
+
}
|
|
4485
|
+
if(best) pulse(best, '👋');
|
|
4486
|
+
break;
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
function walkerPos(w){
|
|
4492
|
+
// Decode w.distance (in units of "segments") into a (seg, t) so
|
|
4493
|
+
// callers can project a real world point without re-walking the
|
|
4494
|
+
// path each time. The 'seg' returned is a continuous float, so a
|
|
4495
|
+
// walker mid-stride is at seg=1.6, for example — used by the
|
|
4496
|
+
// depth sort to get a smooth iso-depth from the world point.
|
|
4497
|
+
var path = w.path, info = w.info;
|
|
4498
|
+
var d = ((w.distance % (path.length-1)) + (path.length-1)) % (path.length-1);
|
|
4499
|
+
var segI = Math.floor(d);
|
|
4500
|
+
var segT = d - segI;
|
|
4501
|
+
var a = path[segI], b = path[segI+1] || path[0];
|
|
4502
|
+
return [ a[0] + (b[0]-a[0])*segT, a[1] + (b[1]-a[1])*segT ];
|
|
4503
|
+
}
|
|
4504
|
+
function drawWalker(w, now){
|
|
4505
|
+
var pos = walkerPos(w);
|
|
4506
|
+
var p = iso(pos[0], pos[1]);
|
|
4507
|
+
var z = cam.z;
|
|
4508
|
+
// Direction along the path: average the next 0.5s of motion so the
|
|
4509
|
+
// figure doesn't snap on segment turns.
|
|
4510
|
+
var lookAhead = walkerPos({path:w.path, info:w.info, distance: w.distance + 0.5});
|
|
4511
|
+
var lh = iso(lookAhead[0], lookAhead[1]);
|
|
4512
|
+
var facingRight = (lh.x - p.x) >= 0;
|
|
4513
|
+
// Gait cycle: tied to distance travelled (not wall clock) so the
|
|
4514
|
+
// animation is smooth at any speed. ~1 step per 0.4 world units.
|
|
4515
|
+
var phase = w.distance * 2.4;
|
|
4516
|
+
// Use a cosine for the step so foot-down poses linger briefly at
|
|
4517
|
+
// each extreme — reads as a real walk rather than a sine wobble.
|
|
4518
|
+
var stepC = Math.cos(phase);
|
|
4519
|
+
var stepS = Math.sin(phase);
|
|
4520
|
+
// Bob: only on the up-phase of the step (plant foot + rise).
|
|
4521
|
+
var bob = Math.max(0, stepC) * 1.0 * z;
|
|
4522
|
+
// Body lean: very slight forward lean into the direction of travel.
|
|
4523
|
+
var lean = 0.6 * z;
|
|
4524
|
+
var greeting = now < w.pauseUntil;
|
|
4525
|
+
// Anchor on the ground (shadow), body sits above.
|
|
4526
|
+
var gx = p.x, gy = p.y + 2*z;
|
|
4527
|
+
X.save();
|
|
4528
|
+
if(!facingRight){ X.translate(gx, 0); X.scale(-1, 1); X.translate(-gx, 0); }
|
|
4529
|
+
// shadow ellipse — softens and shrinks while in the air
|
|
4530
|
+
X.fillStyle='rgba(0,0,0,0.28)';
|
|
4531
|
+
X.beginPath(); X.ellipse(gx, gy, (4.6 - bob*0.5)*z, 1.8*z, 0, 0, Math.PI*2); X.fill();
|
|
4532
|
+
// legs (two angled lines from the hip, swinging opposite with cosine)
|
|
4533
|
+
var hipX = gx + lean, hipY = gy - 9*z + bob;
|
|
4534
|
+
var legSpread = 2.6*z;
|
|
4535
|
+
var legSwing = stepC * 3.0*z;
|
|
4536
|
+
X.strokeStyle = w.spec.pants; X.lineWidth = 1.9*z; X.lineCap = 'round';
|
|
4537
|
+
// Back leg (planted)
|
|
4538
|
+
X.beginPath();
|
|
4539
|
+
X.moveTo(hipX - legSpread*0.5, hipY);
|
|
4540
|
+
X.lineTo(hipX - legSpread*0.5 - legSwing, gy);
|
|
4541
|
+
X.stroke();
|
|
4542
|
+
// Front leg (swinging forward)
|
|
4543
|
+
X.beginPath();
|
|
4544
|
+
X.moveTo(hipX + legSpread*0.5, hipY);
|
|
4545
|
+
X.lineTo(hipX + legSpread*0.5 + legSwing, gy);
|
|
4546
|
+
X.stroke();
|
|
4547
|
+
// shoes — small dark caps at the foot so the step reads
|
|
4548
|
+
X.fillStyle = '#1a1410';
|
|
4549
|
+
X.beginPath(); X.ellipse(hipX - legSpread*0.5 - legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();
|
|
4550
|
+
X.beginPath(); X.ellipse(hipX + legSpread*0.5 + legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();
|
|
4551
|
+
// torso (shirt) — slightly tilted into the lean
|
|
4552
|
+
X.save();
|
|
4553
|
+
X.translate(hipX, hipY - 4*z);
|
|
4554
|
+
X.rotate(lean / 10);
|
|
4555
|
+
X.fillStyle = w.spec.shirt;
|
|
4556
|
+
X.beginPath(); X.roundRect(-3.2*z, -3.6*z, 6.4*z, 7.2*z, 1.6*z); X.fill();
|
|
4557
|
+
// belt
|
|
4558
|
+
X.fillStyle = 'rgba(0,0,0,0.25)';
|
|
4559
|
+
X.fillRect(-3.2*z, 2.4*z, 6.4*z, 0.9*z);
|
|
4560
|
+
X.restore();
|
|
4561
|
+
// arms (opposite-phase to the legs for a natural gait)
|
|
4562
|
+
X.strokeStyle = w.spec.shirt; X.lineWidth = 1.6*z; X.lineCap = 'round';
|
|
4563
|
+
var armSwing = stepC * 1.6*z;
|
|
4564
|
+
if(greeting){
|
|
4565
|
+
// waving arm: raised and gently flicking
|
|
4566
|
+
var flick = Math.sin(now/85) * 1.2*z;
|
|
4567
|
+
X.beginPath();
|
|
4568
|
+
X.moveTo(hipX - 2.8*z, hipY - 6*z);
|
|
4569
|
+
X.lineTo(hipX - 5*z + flick, hipY - 12*z);
|
|
4570
|
+
X.stroke();
|
|
4571
|
+
// resting arm — small bend so it doesn't look stiff
|
|
4572
|
+
X.beginPath();
|
|
4573
|
+
X.moveTo(hipX + 2.8*z, hipY - 6*z);
|
|
4574
|
+
X.lineTo(hipX + 4.4*z, hipY - 1.4*z);
|
|
4575
|
+
X.stroke();
|
|
4576
|
+
} else {
|
|
4577
|
+
X.beginPath();
|
|
4578
|
+
X.moveTo(hipX - 2.8*z, hipY - 6*z);
|
|
4579
|
+
X.lineTo(hipX - 4.2*z - armSwing, hipY - 0.6*z);
|
|
4580
|
+
X.moveTo(hipX + 2.8*z, hipY - 6*z);
|
|
4581
|
+
X.lineTo(hipX + 4.2*z + armSwing, hipY - 0.6*z);
|
|
4582
|
+
X.stroke();
|
|
4583
|
+
}
|
|
4584
|
+
// head (skin tone circle) — slight up/down nod with the step
|
|
4585
|
+
var headY = hipY - 11*z + bob*0.4;
|
|
4586
|
+
X.fillStyle = '#e8c39a';
|
|
4587
|
+
X.beginPath(); X.arc(hipX + lean*0.3, headY, 2.2*z, 0, Math.PI*2); X.fill();
|
|
4588
|
+
// hair cap (slightly different for each walker for visual variety)
|
|
4589
|
+
X.fillStyle = w.idx % 3 === 0 ? '#2a2018' : (w.idx % 3 === 1 ? '#4a2a18' : '#1a1810');
|
|
4590
|
+
X.beginPath(); X.arc(hipX + lean*0.3, headY - 0.9*z, 2.2*z, Math.PI*1.05, Math.PI*1.95); X.fill();
|
|
4591
|
+
// hat / accessory variant for walker 4 & 5 so they read as distinct characters
|
|
4592
|
+
if(w.idx === 4 || w.idx === 5){
|
|
4593
|
+
X.fillStyle = w.idx === 4 ? '#3a4a6a' : '#7a5a2a';
|
|
4594
|
+
X.beginPath(); X.ellipse(hipX + lean*0.3, headY - 1.7*z, 2.6*z, 0.7*z, 0, 0, Math.PI*2); X.fill();
|
|
4595
|
+
}
|
|
4596
|
+
X.restore();
|
|
4597
|
+
}
|
|
4598
|
+
|
|
1580
4599
|
function frame(){
|
|
1581
4600
|
if(!running) return;
|
|
4601
|
+
var now = performance.now();
|
|
1582
4602
|
X.clearRect(0,0,W,H);
|
|
1583
4603
|
drawGround();
|
|
1584
|
-
//
|
|
4604
|
+
// step the looping walkers along their fixed paths
|
|
4605
|
+
tickWalkers(now);
|
|
4606
|
+
// Re-derive status every frame so the "ended" countdown (a wall-
|
|
4607
|
+
// clock timer) decrements smoothly between telemetry polls (3s).
|
|
4608
|
+
for(var sni=0; sni<nodes.length; sni++){
|
|
4609
|
+
var sn = nodes[sni];
|
|
4610
|
+
if(sn.kind === 'robot'){
|
|
4611
|
+
var st = computeStatus(sn.telemetry, sn);
|
|
4612
|
+
sn.status = st.status;
|
|
4613
|
+
sn.readiness = st.readiness;
|
|
4614
|
+
sn.readinessMessage = st.readinessMessage;
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
// depth-sort palms + nodes + walkers so nearer objects overlap farther ones
|
|
1585
4618
|
var objs=[];
|
|
1586
4619
|
for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }
|
|
1587
4620
|
for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
|
|
4621
|
+
for(var wii=0; wii<walkers.length; wii++){ var wpos=walkerPos(walkers[wii]); objs.push({wx:wpos[0],wy:wpos[1],walker:walkers[wii]}); }
|
|
1588
4622
|
objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
|
|
1589
|
-
objs.forEach(function(o){
|
|
4623
|
+
objs.forEach(function(o){
|
|
4624
|
+
if(o.palm) drawPalm(o.wx,o.wy);
|
|
4625
|
+
else if(o.walker) drawWalker(o.walker, now);
|
|
4626
|
+
else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); }
|
|
4627
|
+
});
|
|
1590
4628
|
for(var i=orbs.length-1;i>=0;i--){ var o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}
|
|
1591
4629
|
var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;
|
|
1592
4630
|
var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;
|
|
@@ -1662,7 +4700,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1662
4700
|
// ── robot drawer ──
|
|
1663
4701
|
function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
|
|
1664
4702
|
function openDrawer(id){
|
|
1665
|
-
var n=byId[id]; if(!n) return; drawerId=id;
|
|
4703
|
+
var n=byId[id]; if(!n) return; drawerId=id; deviceSelectorsReady=false;
|
|
1666
4704
|
$('pk-drawer').classList.add('open');
|
|
1667
4705
|
var av=$('pk-av'), avSrc=avatarSrcForNode(n), avImg=avatarImageForNode(n);
|
|
1668
4706
|
if(av){
|
|
@@ -1687,11 +4725,33 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1687
4725
|
// enrolled device's id is a generated dev_xxxx, distinct from its name).
|
|
1688
4726
|
// Falls back to n.name if telemetry hasn't matched a row yet.
|
|
1689
4727
|
function schedRow(n){
|
|
1690
|
-
var row=roboparkByName[n.name];
|
|
1691
|
-
|
|
1692
|
-
|
|
4728
|
+
var row=roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
|
|
4729
|
+
var seen={}, candidates=[];
|
|
4730
|
+
for(var rk in roboparkByName){
|
|
4731
|
+
var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
|
|
4732
|
+
if(!candidate||!cid||seen[cid]) continue;
|
|
4733
|
+
seen[cid]=true;
|
|
4734
|
+
var label=String(candidate.name||'').toLowerCase(), idlabel=String(cid).toLowerCase(), matches=false;
|
|
4735
|
+
for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }
|
|
4736
|
+
if(matches) candidates.push(candidate);
|
|
4737
|
+
}
|
|
4738
|
+
function readyScore(candidate){
|
|
4739
|
+
var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
|
|
4740
|
+
if(candidate.production_mode===true||device.production_mode===true) score+=1000;
|
|
4741
|
+
if(status==='online'||status==='running'||status==='detecting') score+=500;
|
|
4742
|
+
if(candidate.readiness_code==='ready') score+=250;
|
|
4743
|
+
if(candidate===exact) score+=10;
|
|
4744
|
+
return score;
|
|
4745
|
+
}
|
|
4746
|
+
candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });
|
|
4747
|
+
return candidates[0]||row;
|
|
4748
|
+
}
|
|
4749
|
+
function schedId(n){
|
|
4750
|
+
var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
|
|
4751
|
+
if(n.schedulerRobotId) return n.schedulerRobotId;
|
|
4752
|
+
var byName=schedulerDevicesByName[n.name]; if(byName&&byName.id) return byName.id;
|
|
4753
|
+
return n.name;
|
|
1693
4754
|
}
|
|
1694
|
-
function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }
|
|
1695
4755
|
function closeDrawer(){
|
|
1696
4756
|
// tell the scheduler to end any live preview so the robot stops its cam
|
|
1697
4757
|
if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
|
|
@@ -1753,6 +4813,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1753
4813
|
}).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });
|
|
1754
4814
|
}
|
|
1755
4815
|
|
|
4816
|
+
// Per-robot production mode: PATCHes the device row, then immediately
|
|
4817
|
+
// arms/disarms this robot's continuous motion-triggered loop + always-on
|
|
4818
|
+
// camera feed rather than waiting for the next poll cycle to notice.
|
|
4819
|
+
function setProductionMode(n, on){
|
|
4820
|
+
var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
|
|
4821
|
+
rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),{production_mode:!!on}).then(function(){
|
|
4822
|
+
showOut('production mode '+(on?'enabled':'disabled')+' for '+(device.name||id));
|
|
4823
|
+
if(device) device.production_mode=!!on;
|
|
4824
|
+
if(on && !previewOn && !lkRoom) startCam(n);
|
|
4825
|
+
if(!on && previewOn) stopPreview(n);
|
|
4826
|
+
}).catch(function(e){ var pm=$('pk-prodmode'); if(pm) pm.checked=!on; showOut('production mode ('+id+'): '+e.message); });
|
|
4827
|
+
}
|
|
1756
4828
|
// ── on-demand operator preview: open the robot's cam without a scene-detection session ──
|
|
1757
4829
|
function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
|
|
1758
4830
|
function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
|
|
@@ -1766,12 +4838,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1766
4838
|
.then(function(info){
|
|
1767
4839
|
if(!previewOn) return; // stopped while awaiting
|
|
1768
4840
|
if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }
|
|
1769
|
-
if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview · robot opening camera…'; }
|
|
4841
|
+
if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(n); if(note) note.textContent='live preview · robot opening camera…'; }
|
|
1770
4842
|
else { idleCam('no-sdk'); }
|
|
1771
4843
|
if(previewTimer) clearInterval(previewTimer);
|
|
1772
4844
|
previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
|
|
1773
4845
|
})
|
|
1774
|
-
.catch(function(){ idleCam('unreachable'); if(note) note.textContent='
|
|
4846
|
+
.catch(function(e){ idleCam('unreachable'); if(note) note.textContent='preview request failed'; if(window.console) console.error('RoboPark preview request failed',e); previewOn=false; updateGoLive(); });
|
|
1775
4847
|
}
|
|
1776
4848
|
function stopPreview(n){
|
|
1777
4849
|
previewOn=false; updateGoLive();
|
|
@@ -1784,22 +4856,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1784
4856
|
// RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),
|
|
1785
4857
|
// so the browser hits the robot's LAN IP straight on, no scheduler proxy.
|
|
1786
4858
|
// The scheduler learns that IP from the device's own heartbeats.
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
return;
|
|
4859
|
+
// LiveKit is the authoritative camera feed. The standalone OpenCV MJPEG
|
|
4860
|
+
// server is opt-in because running it beside preview_agent would open the
|
|
4861
|
+
// same Windows camera twice and make both feeds unreliable.
|
|
4862
|
+
function tryVisionOverlay(n){
|
|
4863
|
+
var img=$('pk-vision-img'); if(!img) return;
|
|
4864
|
+
if(String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){
|
|
4865
|
+
visionOn=false; img.src=''; img.style.display='none'; return;
|
|
1794
4866
|
}
|
|
1795
|
-
var row=schedRow(n);
|
|
1796
|
-
|
|
1797
|
-
if(!ip){ if(note) note.textContent='no known IP for this robot yet — waiting for a heartbeat with lan_ip'; return; }
|
|
4867
|
+
var row=schedRow(n), ip=row&&row.lan_ip;
|
|
4868
|
+
if(!ip){ visionOn=false; img.src=''; img.style.display='none'; return; }
|
|
1798
4869
|
var port=(row&&row.vision_port)||5000;
|
|
1799
4870
|
visionOn=true;
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
4871
|
+
img.onerror=function(){ visionOn=false; img.style.display='none'; img.src=''; };
|
|
4872
|
+
img.onload=function(){ img.style.display='block'; };
|
|
4873
|
+
img.src='http://'+ip+':'+port+'/video_feed';
|
|
1803
4874
|
}
|
|
1804
4875
|
function stopVision(){
|
|
1805
4876
|
visionOn=false;
|
|
@@ -1812,6 +4883,89 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1812
4883
|
refreshDrawer();
|
|
1813
4884
|
}).catch(function(){});
|
|
1814
4885
|
}
|
|
4886
|
+
function schedulerDevice(n){
|
|
4887
|
+
var row=schedRow(n), id=row&&(row.robot_id||row.id);
|
|
4888
|
+
return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[n.name] || schedulerDevicesById[n.name] || null;
|
|
4889
|
+
}
|
|
4890
|
+
function deviceList(inv, key, fallback){
|
|
4891
|
+
var list=inv&&Array.isArray(inv[key])?inv[key]:[];
|
|
4892
|
+
return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });
|
|
4893
|
+
}
|
|
4894
|
+
function hasRealInventory(inv){ return !!(inv && ((inv.video&&inv.video.length>2) || (inv.audio_input&&inv.audio_input.length>1) || (inv.audio_output&&inv.audio_output.length>1))); }
|
|
4895
|
+
function refreshDeviceSelectors(n){
|
|
4896
|
+
var device=schedulerDevice(n)||{}, inv=device.device_inventory||{};
|
|
4897
|
+
if(hasRealInventory(inv)) deviceSelectorsReady=true;
|
|
4898
|
+
var search=$('pk-device-search'), query=(search&&search.value||'').trim().toLowerCase();
|
|
4899
|
+
var video=deviceList(inv,'video',['auto','none','picamera','/dev/video0','/dev/video1','/dev/video2']);
|
|
4900
|
+
var input=deviceList(inv,'audio_input',['default','none']);
|
|
4901
|
+
var output=deviceList(inv,'audio_output',['default','none']);
|
|
4902
|
+
function fill(id, list, selected){
|
|
4903
|
+
var el=$(id); if(!el) return;
|
|
4904
|
+
el.innerHTML='';
|
|
4905
|
+
list.filter(function(x){ return !query || String(x.name||x.id).toLowerCase().indexOf(query)>-1 || String(x.id).toLowerCase().indexOf(query)>-1; }).forEach(function(x){
|
|
4906
|
+
var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(selected||'')) o.selected=true; el.appendChild(o);
|
|
4907
|
+
});
|
|
4908
|
+
if(!el.value && selected){ var o=document.createElement('option'); o.value=String(selected); o.textContent=String(selected)+' (saved)'; o.selected=true; el.appendChild(o); }
|
|
4909
|
+
}
|
|
4910
|
+
fill('pk-video-device', video, device.video_device||'auto');
|
|
4911
|
+
fill('pk-audio-input', input, device.audio_device||'default');
|
|
4912
|
+
fill('pk-audio-output', output, device.audio_output_device||'default');
|
|
4913
|
+
var greetings=$('pk-greeting-phrases');
|
|
4914
|
+
if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
|
|
4915
|
+
var note=$('pk-device-note'); if(note) note.textContent=(device.id?'inventory from '+(device.name||device.id):'waiting for robot heartbeat inventory');
|
|
4916
|
+
}
|
|
4917
|
+
function saveDeviceSelectors(n){
|
|
4918
|
+
var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
|
|
4919
|
+
var body={video_device:$('pk-video-device').value, audio_device:$('pk-audio-input').value, audio_output_device:$('pk-audio-output').value,
|
|
4920
|
+
greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};
|
|
4921
|
+
rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(){
|
|
4922
|
+
showOut('camera and microphone selection saved for '+(device.name||id));
|
|
4923
|
+
}).catch(function(e){ showOut('device selection: '+e.message); });
|
|
4924
|
+
}
|
|
4925
|
+
function fmtUptime(secs){
|
|
4926
|
+
if(secs==null) return '';
|
|
4927
|
+
secs=Math.round(secs);
|
|
4928
|
+
if(secs<60) return secs+'s';
|
|
4929
|
+
if(secs<3600) return Math.round(secs/60)+'m';
|
|
4930
|
+
return Math.round(secs/3600)+'h';
|
|
4931
|
+
}
|
|
4932
|
+
function restartService(n, svcName, btn){
|
|
4933
|
+
var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
|
|
4934
|
+
if(btn){ btn.disabled=true; btn.textContent='queuing…'; }
|
|
4935
|
+
rpAction('POST','/robopark/api/devices/'+encodeURIComponent(id)+'/services/'+encodeURIComponent(svcName)+'/restart',{}).then(function(){
|
|
4936
|
+
showOut('restart queued for '+svcName+' — picked up on the next robot check-in, up to about 10s');
|
|
4937
|
+
if(btn){ btn.textContent='queued'; }
|
|
4938
|
+
}).catch(function(e){
|
|
4939
|
+
showOut('restart ('+svcName+'): '+e.message);
|
|
4940
|
+
if(btn){ btn.disabled=false; btn.textContent='restart'; }
|
|
4941
|
+
});
|
|
4942
|
+
}
|
|
4943
|
+
function refreshSupervisorPanel(n){
|
|
4944
|
+
var list=$('pk-svc-list'), age=$('pk-svc-age'); if(!list) return;
|
|
4945
|
+
var device=schedulerDevice(n);
|
|
4946
|
+
var services=device&&device.supervisor_status;
|
|
4947
|
+
var reportedAt=device&&device.supervisor_status_at;
|
|
4948
|
+
if(!services || !services.length){
|
|
4949
|
+
list.innerHTML='<div class="pk-note">no supervisor report yet — either robot_supervisor.py is not running on this robot, or it has not enrolled (no device_token) yet</div>';
|
|
4950
|
+
if(age) age.textContent='';
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4953
|
+
list.innerHTML=services.map(function(s){
|
|
4954
|
+
var cls=!s.enabled?'disabled':(s.running?'up':'down');
|
|
4955
|
+
var meta=!s.enabled?'disabled'
|
|
4956
|
+
: s.running?('pid '+s.pid+' · up '+fmtUptime(s.uptime_seconds)+(s.failure_count?(' · '+s.failure_count+' restart'+(s.failure_count===1?'':'s')+' so far'):''))
|
|
4957
|
+
: ('down'+(s.last_exit_code!=null?(' · exit '+s.last_exit_code):'')+(s.failure_count?(' · retry #'+s.failure_count):''));
|
|
4958
|
+
var btn=s.enabled?('<button class="rp-btn pk-svc-restart-btn" data-svc="'+esc(s.name)+'">restart</button>'):'';
|
|
4959
|
+
return '<div class="pk-svc-row '+cls+'"><span class="pk-svc-dot"></span><span class="pk-svc-name">'+esc(s.name)+'</span><span class="pk-svc-meta">'+esc(meta)+'</span>'+btn+'</div>';
|
|
4960
|
+
}).join('');
|
|
4961
|
+
if(age){
|
|
4962
|
+
if(reportedAt){
|
|
4963
|
+
var ageSec=(Date.now()-new Date(reportedAt+'Z').getTime())/1000;
|
|
4964
|
+
age.textContent = ageSec>30 ? ('⚠ last report '+fmtUptime(ageSec)+' ago — supervisor may be down') : ('last report '+fmtUptime(ageSec)+' ago');
|
|
4965
|
+
age.style.color = ageSec>30 ? 'var(--bad)' : '';
|
|
4966
|
+
} else { age.textContent=''; }
|
|
4967
|
+
}
|
|
4968
|
+
}
|
|
1815
4969
|
function buildDrawer(n){
|
|
1816
4970
|
var b=$('pk-body');
|
|
1817
4971
|
if(n.kind==='robot' && !n.connected){
|
|
@@ -1841,7 +4995,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1841
4995
|
+ '<div class="rp-field"><label>Voice stack override</label><select id="pk-setup-voice"><option value="">— from character preset —</option>'+voiceOptions+'</select></div>'
|
|
1842
4996
|
+ '</div></details>'
|
|
1843
4997
|
+ '</div>'
|
|
1844
|
-
+ '<div class="pk-sect" id="pk-setup-result" style="display:none;"><h4>Enrollment token (copy once)</h4><input id="pk-setup-token"
|
|
4998
|
+
+ '<div class="pk-sect" id="pk-setup-result" style="display:none;"><h4>Enrollment token (copy once)</h4><input id="pk-setup-token" readonly style="margin-bottom:0.4rem;"><button class="rp-btn" id="pk-setup-copy">Copy</button></div>');
|
|
1845
4999
|
var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });
|
|
1846
5000
|
var copyBtn=$('pk-setup-copy'); if(copyBtn) copyBtn.addEventListener('click', function(){ var t=$('pk-setup-token'); if(t){ t.select(); try{document.execCommand('copy');}catch(e){} showOut('token copied'); } });
|
|
1847
5001
|
function refreshPadCliCommand(){
|
|
@@ -1858,26 +5012,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1858
5012
|
var effPresetOptions='', effVoiceOptions='';
|
|
1859
5013
|
for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value="'+esc(cp.id||'')+'"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }
|
|
1860
5014
|
for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value="'+esc(vs.id||'')+'">'+esc(vs.name||vs.id||'')+'</option>'; }
|
|
5015
|
+
var devForPm=schedulerDevice(n)||{};
|
|
1861
5016
|
b.innerHTML =
|
|
1862
5017
|
sect('👁 camera feed',
|
|
1863
5018
|
'<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
|
|
5019
|
+
+'<img id="pk-vision-img" style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0;display:none;border-radius:inherit" alt="vision feed"/>'
|
|
1864
5020
|
+'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
|
|
1865
|
-
+'<div class="pk-note" id="pk-camnote">seeking
|
|
1866
|
-
+'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
|
|
1867
|
-
|
|
1868
|
-
'<div class="pk-cam" id="pk-vision-wrap"><img id="pk-vision-img" style="width:100%;display:none;border-radius:inherit" alt="vision feed"/></div>'
|
|
1869
|
-
+'<div class="pk-note" id="pk-vision-note">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'
|
|
1870
|
-
+'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-vision-toggle">🔍 Show overlay</button></div>')
|
|
5021
|
+
+'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
|
|
5022
|
+
+'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
|
|
5023
|
+
+'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
|
|
1871
5024
|
+ sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
|
|
1872
5025
|
+ sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
|
|
1873
5026
|
+ sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
|
|
1874
5027
|
+ sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
|
|
1875
5028
|
+ sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>')
|
|
5029
|
+
+ sect('🩺 robot services','<div id="pk-svc-list" class="pk-svc-list"><div class="pk-note">waiting for supervisor report…</div></div><div class="pk-note" id="pk-svc-age" style="margin-top:0.35rem;"></div><div class="rp-field" style="margin-top:0.55rem;"><label>Quick run (on the robot, from packages/robopark/scheduler)</label><input id="pk-svc-clicmd" readonly value="python robot_supervisor.py"></div><button class="rp-btn" id="pk-svc-clicmd-copy" style="margin-top:0.35rem;">📋 Copy command</button>')
|
|
1876
5030
|
+ sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><button class="rp-btn" id="pk-eff-save" data-robot="'+esc(schedId(n))+'" style="margin-top:0.35rem;">Set up on this robot</button>');
|
|
1877
|
-
|
|
5031
|
+
b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
|
|
5032
|
+
refreshDeviceSelectors(n);
|
|
5033
|
+
b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
|
|
5034
|
+
var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
|
|
5035
|
+
var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
|
|
5036
|
+
startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
|
|
1878
5037
|
var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
|
|
1879
|
-
var
|
|
5038
|
+
var pm=$('pk-prodmode'); if(pm) pm.addEventListener('change', function(){ if(rpReadOnly){ pm.checked=!pm.checked; showOut('read-only node'); return; } setProductionMode(n, pm.checked); });
|
|
5039
|
+
var svcCopy=$('pk-svc-clicmd-copy'); if(svcCopy) svcCopy.addEventListener('click', function(){ var i=$('pk-svc-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied — run it on the robot itself'); });
|
|
5040
|
+
var svcList=$('pk-svc-list'); if(svcList) svcList.addEventListener('click', function(ev){
|
|
5041
|
+
var btn=ev.target.closest('.pk-svc-restart-btn'); if(!btn) return;
|
|
5042
|
+
if(rpReadOnly){ showOut('read-only node'); return; }
|
|
5043
|
+
restartService(n, btn.dataset.svc, btn);
|
|
5044
|
+
});
|
|
1880
5045
|
var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
|
|
5046
|
+
var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
|
|
1881
5047
|
var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
|
|
1882
5048
|
} else {
|
|
1883
5049
|
b.innerHTML =
|
|
@@ -1890,16 +5056,31 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1890
5056
|
}
|
|
1891
5057
|
function refreshDrawer(){
|
|
1892
5058
|
var n=drawerId?byId[drawerId]:null; if(!n) return;
|
|
5059
|
+
if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){
|
|
5060
|
+
var devCheck=schedulerDevice(n);
|
|
5061
|
+
if(devCheck && hasRealInventory(devCheck.device_inventory)) refreshDeviceSelectors(n);
|
|
5062
|
+
}
|
|
5063
|
+
if(n.kind==='robot' && $('pk-cam')){
|
|
5064
|
+
var camDev=schedulerDevice(n);
|
|
5065
|
+
// Self-heal: a production-mode robot's feed should never sit idle for
|
|
5066
|
+
// long — if it's not currently live (no session, no preview running),
|
|
5067
|
+
// re-arm it. Also keep retrying the opencv vision overlay in case the
|
|
5068
|
+
// robot's lan_ip only became known after this panel was first drawn.
|
|
5069
|
+
if(camDev && camDev.production_mode && !previewOn && !lkRoom) startCam(n);
|
|
5070
|
+
if(!visionOn && lkRoom) tryVisionOverlay(n);
|
|
5071
|
+
}
|
|
5072
|
+
if(n.kind==='robot' && $('pk-svc-list')) refreshSupervisorPanel(n);
|
|
1893
5073
|
setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
|
|
1894
5074
|
var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
|
|
1895
5075
|
var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
|
|
1896
5076
|
var row=schedRow(n);
|
|
5077
|
+
var ready=$('pk-readiness'); if(ready && row){ ready.textContent=(row.readiness_code||'unknown').replace(/_/g,' ')+' / '+(row.readiness_message||'no readiness detail'); ready.style.color=(row.readiness_code==='ready'||row.readiness_code==='in_session')?'var(--ok)':'var(--bad)'; }
|
|
1897
5078
|
if(row){
|
|
1898
5079
|
set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
|
|
1899
5080
|
set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
|
|
1900
5081
|
set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
|
|
1901
5082
|
var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
|
|
1902
|
-
var active=row.active_session||row.in_session||row.session_active;
|
|
5083
|
+
var active=row.active_session||row.in_session||row.session_active||row.current_session_id||row.status==='connecting'||row.status==='running';
|
|
1903
5084
|
var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }
|
|
1904
5085
|
}
|
|
1905
5086
|
}
|
|
@@ -1916,9 +5097,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1916
5097
|
fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
|
|
1917
5098
|
.then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
|
|
1918
5099
|
.then(function(info){
|
|
1919
|
-
if(info && info.active===false){
|
|
5100
|
+
if(info && info.active===false){
|
|
5101
|
+
// No real session running. If this robot is in production mode,
|
|
5102
|
+
// keep its camera live via the same on-demand preview path "Go
|
|
5103
|
+
// live" uses, instead of showing an idle placeholder — production
|
|
5104
|
+
// mode means the feed should always be visible.
|
|
5105
|
+
// Production mode leaves the idle camera with the robot's motion
|
|
5106
|
+
// detector. Operator preview is started explicitly with Go live.
|
|
5107
|
+
idleCam(info.reason||'idle');
|
|
5108
|
+
return;
|
|
5109
|
+
}
|
|
1920
5110
|
if(info && info.url && info.token){
|
|
1921
|
-
if(window.LivekitClient){ attachLiveKit(info, n); }
|
|
5111
|
+
if(window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(n); }
|
|
1922
5112
|
else { idleCam('no-sdk'); }
|
|
1923
5113
|
} else { idleCam('idle'); }
|
|
1924
5114
|
})
|
|
@@ -1939,11 +5129,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1939
5129
|
var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
|
|
1940
5130
|
var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
|
|
1941
5131
|
if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
|
|
1942
|
-
} else if(track.kind==='audio'){
|
|
5132
|
+
} else if(track.kind==='audio'){
|
|
5133
|
+
hookMicAnalyser(track);
|
|
5134
|
+
// Muted by default: this is the camera-preview panel, running on
|
|
5135
|
+
// the SAME physical device as the robot in local testing. Auto-
|
|
5136
|
+
// playing the subscribed track (robot mic, or later the agent's
|
|
5137
|
+
// TTS) through the browser's speakers next to the robot's own
|
|
5138
|
+
// mic closes an acoustic feedback loop — heard as a nonstop echo
|
|
5139
|
+
// that drowns out the actual conversation. The VU meter above
|
|
5140
|
+
// (hookMicAnalyser) taps the raw track directly and works fine
|
|
5141
|
+
// muted.
|
|
5142
|
+
var audio=track.attach(); audio.autoplay=true; audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
|
|
5143
|
+
audio.play().catch(function(){});
|
|
5144
|
+
}
|
|
1943
5145
|
});
|
|
1944
5146
|
lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
|
|
1945
|
-
|
|
1946
|
-
|
|
5147
|
+
var livekitUrl=info.url;
|
|
5148
|
+
if((location.hostname==='localhost'||location.hostname==='127.0.0.1') && /^ws:\\/\\/localhost(?::|\\/)/i.test(livekitUrl)) livekitUrl=livekitUrl.replace(/^ws:\\/\\/localhost/i,'ws://127.0.0.1');
|
|
5149
|
+
lkRoom.connect(livekitUrl, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
|
|
5150
|
+
.catch(function(e){ idleCam('unreachable'); if(note) note.textContent='LiveKit connection failed'; if(window.console) console.error('RoboPark LiveKit connection failed',e); });
|
|
1947
5151
|
}catch(e){ idleCam('no-sdk'); }
|
|
1948
5152
|
}
|
|
1949
5153
|
// Real mic level: tap the robot's subscribed 'mic' audio track with a Web
|
|
@@ -1951,7 +5155,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1951
5155
|
function hookMicAnalyser(track){
|
|
1952
5156
|
try{
|
|
1953
5157
|
var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
|
|
1954
|
-
audioCtx=audioCtx||new AC();
|
|
5158
|
+
audioCtx=audioCtx||new AC(); if(audioCtx.state==='suspended') audioCtx.resume().catch(function(){});
|
|
1955
5159
|
var ms=track.mediaStreamTrack; if(!ms) return;
|
|
1956
5160
|
var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
|
|
1957
5161
|
var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
|
|
@@ -2004,13 +5208,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2004
5208
|
}
|
|
2005
5209
|
|
|
2006
5210
|
$('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
|
|
2007
|
-
return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };
|
|
5211
|
+
return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots, updateTelemetry:updateTelemetry };
|
|
2008
5212
|
})();
|
|
2009
5213
|
|
|
2010
5214
|
poll();
|
|
2011
5215
|
setInterval(poll, 2000);
|
|
2012
5216
|
setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
|
|
2013
5217
|
setInterval(loadParkSlots, 7000);
|
|
5218
|
+
// Pre-load character presets and voice stacks on startup so the Park
|
|
5219
|
+
// robot-detail drawer has populated dropdowns even if the user never visits the robopark tab.
|
|
5220
|
+
loadCharacterPresets();
|
|
5221
|
+
loadVoiceStacks();
|
|
5222
|
+
setInterval(function(){ loadCharacterPresets(); loadVoiceStacks(); }, 12000);
|
|
2014
5223
|
initTab();
|
|
2015
5224
|
})();
|
|
2016
5225
|
</script>
|