Files
yovision/docs/design/brain/index.html
QiuSW a2790c1f5e
Harness governance / validate (pull_request) Has been cancelled
feat(brain): add single-stream visual prototype (T-017)
2026-08-11 11:02:37 +08:00

477 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
<link rel="icon" href="data:,">
<title>YoVision Brain · 单路区域事件工程原型</title>
<style>
:root {
--bg: #0b1120;
--surface: #111a2b;
--surface-2: #172236;
--surface-3: #1e2c43;
--text: #f5f7fb;
--muted: #aebbd0;
--subtle: #8291a9;
--line: #2b3a52;
--primary: #8b7cf6;
--primary-strong: #7567e8;
--cyan: #38bdf8;
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--focus: #b8e5ff;
--radius: 14px;
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
.skip-link:focus { transform: none; }
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
.brand strong, .brand span { display: block; }
.brand strong { font-size: 16px; }
.brand span { color: var(--muted); font-size: 12px; }
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
main { padding-top: 18px; }
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
.card-head h2 { margin: 0; font-size: 15px; }
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
.btn:disabled { cursor: not-allowed; opacity: .45; }
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
.btn-quiet { background: transparent; }
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.video-stage img { object-fit: contain; background: #050912; }
.video-stage canvas { touch-action: manipulation; }
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
.legend .zone-key { background: var(--warning); }
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.metric span { display: block; color: var(--muted); font-size: 12px; }
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
.side-stack { display: grid; align-content: start; gap: 14px; }
.form-body { padding: 14px; display: grid; gap: 12px; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.event strong { font-size: 13px; }
.event time, .event code { color: var(--muted); font-size: 12px; }
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
.architecture-note strong { color: var(--text); }
.architecture-note p { margin: 2px 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 700px) {
.app-shell { padding-inline: 12px; }
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
.top-context { margin-left: 0; justify-content: flex-start; }
.page-head { flex-direction: column; }
.mode-badge { white-space: normal; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.side-stack { grid-template-columns: 1fr; }
.card-head { align-items: flex-start; flex-direction: column; }
.toolbar { width: 100%; }
.toolbar .btn { flex: 1; }
.video-footer { align-items: flex-start; flex-direction: column; }
.legend { margin-left: 0; }
}
@media (max-width: 420px) {
.prototype-banner { letter-spacing: 0; }
.chip { width: 100%; justify-content: flex-start; }
.toolbar { display: grid; grid-template-columns: 1fr; }
.toolbar .btn { width: 100%; min-width: 0; }
.metrics { grid-template-columns: 1fr 1fr; }
.metric strong { font-size: 17px; }
.form-actions { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<div class="prototype-banner">
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
</div>
<div class="app-shell">
<header class="topbar">
<div class="brand" aria-label="YoVision Brain">
<div class="brand-mark" aria-hidden="true">Y</div>
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
</div>
<div class="top-context" aria-label="运行上下文">
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
</div>
</header>
<main id="main" tabindex="-1">
<div class="page-head">
<div>
<h1>单路检测与区域判定</h1>
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。</p>
</div>
<div id="mode-badge" class="mode-badge">加载运行状态</div>
</div>
<div class="grid">
<section class="card" aria-labelledby="preview-title">
<div class="card-head">
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
<div class="toolbar">
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<span>暂停刷新</span>
</button>
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
<span>画布加点</span>
</button>
</div>
</div>
<div class="video-stage">
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
<div id="video-label" class="video-label">等待首帧</div>
</div>
<div class="video-footer">
<span>帧序号 <strong id="sequence">—</strong></span>
<span>采集时间 <strong id="captured-at">—</strong></span>
<div class="legend" aria-label="画面图例">
<span><i aria-hidden="true"></i>匿名人员框</span>
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
</div>
</div>
<div class="metrics" aria-label="推理摘要">
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
</div>
</section>
<aside class="side-stack" aria-label="区域配置与事件候选">
<section class="card" aria-labelledby="zone-title">
<div class="card-head">
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
</div>
<form id="zone-form" class="form-body">
<label for="zone-name">区域名称
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
</label>
<label for="zone-points">顶点坐标(每行 x,y)
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30&#10;0.90,0.30&#10;0.90,0.88&#10;0.55,0.88</textarea>
</label>
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
<div class="form-actions">
<button id="undo-point" class="btn" type="button">撤销一点</button>
<button id="clear-points" class="btn" type="button">清空顶点</button>
</div>
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
</form>
</section>
<section class="card" aria-labelledby="event-title">
<div class="card-head">
<div><h2 id="event-title">最近候选事实</h2><p>只产生 source_event_id;Bell 才生成平台 ULID</p></div>
<span id="event-count" class="chip"><strong>0</strong> 项</span>
</div>
<div id="event-list" class="event-list" aria-live="polite">
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
</div>
</section>
</aside>
</div>
<section class="architecture-note" aria-label="系统边界说明">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
</section>
<div id="announcer" class="sr-only" aria-live="polite"></div>
</main>
</div>
<script>
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const frame = $("video-frame");
const canvas = $("overlay");
const context = canvas.getContext("2d");
const token = document.querySelector('meta[name="brain-demo-token"]').content;
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
let paused = false;
let editMode = false;
let state = null;
let offlineSequence = 0;
let offlineTriggered = false;
const offlineState = () => {
offlineSequence += 1;
const phase = ((offlineSequence - 1) % 160) / 159;
const center = -0.04 + phase * 1.08;
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
const inside = center >= .55 && center <= .90;
const events = state && state.events ? state.events : [];
if (inside && !offlineTriggered) {
offlineTriggered = true;
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
}
if (!inside && center < .50) offlineTriggered = false;
return {
prototype: true,
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
detector: {name: "scripted_prototype", production_ready: false},
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
inference: {target_fps: 2, latency_ms: 0},
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
events,
last_error_code: null
};
};
function parsePoints(showError = true) {
try {
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
const parts = line.split(",").map(value => Number(value.trim()));
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
return {x: parts[0], y: parts[1]};
});
if (points.length < 3 || points.length > 32) throw new Error();
return points;
} catch (_) {
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
return [];
}
}
function setPoints(points) {
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
}
function setFormState(message, error = false) {
const target = $("form-state");
target.textContent = message;
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
}
function drawOfflineBackground() {
context.fillStyle = "#111a2b";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#1e2c43";
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
context.strokeStyle = "#2b3a52";
for (let x = 0; x < canvas.width; x += 80) {
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
}
context.fillStyle = "#7dd3fc";
context.font = "700 18px Segoe UI";
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
}
function drawOverlay() {
if (!state) return;
context.clearRect(0, 0, canvas.width, canvas.height);
if (!runtimeAvailable) drawOfflineBackground();
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
if (points.length >= 3) {
context.beginPath();
points.forEach((point, index) => {
const x = point.x * canvas.width, y = point.y * canvas.height;
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = "rgba(251, 191, 36, .10)";
context.fill();
context.strokeStyle = "#fbbf24";
context.lineWidth = 3;
context.stroke();
points.forEach((point, index) => {
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
context.fillStyle = "#fbbf24"; context.fill();
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
});
}
state.detections.forEach(item => {
const [x1, y1, x2, y2] = item.bbox;
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
context.lineWidth = 3;
context.strokeRect(x, y, width, height);
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
context.font = "700 14px Segoe UI";
const labelWidth = context.measureText(label).width + 14;
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
context.fillStyle = "#fff";
context.fillText(label, x + 7, Math.max(17, y - 8));
});
}
function renderEvents(events) {
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
if (!events.length) {
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
return;
}
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
const article = document.createElement("article"); article.className = "event";
const head = document.createElement("div"); head.className = "event-head";
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
head.append(title, tag);
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
const code = document.createElement("code"); code.textContent = event.source_event_id;
article.append(head, time, code); return article;
}));
}
function render(next) {
state = next;
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
$("source-label").textContent = next.source.label;
$("zone-version").textContent = `v${next.zone.version}`;
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
$("sequence").textContent = String(next.frame.sequence);
$("captured-at").textContent = formatTime(next.frame.captured_at);
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
$("person-count").textContent = String(next.detections.length);
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
}
renderEvents(next.events);
drawOverlay();
}
async function poll() {
if (paused) return;
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/state", {cache: "no-store"});
if (!response.ok) throw new Error();
const next = await response.json();
frame.hidden = false;
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
render(next);
return;
} catch (_) {
runtimeAvailable = false;
frame.hidden = true;
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
}
}
render(offlineState());
}
frame.addEventListener("load", drawOverlay);
$("toggle-preview").addEventListener("click", event => {
paused = !paused;
event.currentTarget.setAttribute("aria-pressed", String(paused));
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
if (!paused) poll();
});
$("edit-zone").addEventListener("click", event => {
editMode = !editMode;
event.currentTarget.setAttribute("aria-pressed", String(editMode));
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
});
canvas.addEventListener("pointerdown", event => {
if (!editMode) return;
const points = parsePoints(false);
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
const rect = canvas.getBoundingClientRect();
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
});
$("undo-point").addEventListener("click", () => {
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
});
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
$("zone-points").addEventListener("input", drawOverlay);
$("zone-form").addEventListener("submit", async event => {
event.preventDefault();
const points = parsePoints(true); if (!points.length) return;
const name = $("zone-name").value.trim();
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
if (!response.ok) throw new Error();
const zone = await response.json();
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
} else {
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
}
button.disabled = false; button.textContent = "保存区域版本";
});
poll();
window.setInterval(poll, 500);
})();
</script>
</body>
</html>