aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend/html/timeline.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-05-05 15:47:48 +0200
committerGitHub Enterprise <[email protected]>2026-05-05 15:47:48 +0200
commit01286c6233347d561064fc9e6cf9deaf2087ceb7 (patch)
treebdbfdf01725baa2d2dd3d73727e6506b41421dff /src/zen/frontend/html/timeline.js
parenthub async s3 client (#1024) (diff)
downloadarchived-zen-main.tar.xz
archived-zen-main.zip
sessions: persist to disk, prune, track client liveness, accept UE_LOGFMT (#1014)HEADmain
Branch started as a sessions-service overhaul (persistence, client liveness, UE_LOGFMT intake) and grew to pick up adjacent infrastructure work: an early-startup log backlog, a hardened `MemoryArena`, the `zen trace serve` viewer gaining a counter view + compact timeline + tabbed callsite panel, defensive fixes in the third-party `tourist` trace parser, a series of allocation reductions across the HTTP and compact-binary hot paths, and a new `zen sessions` CLI command tree. ## Sessions service **Persistence.** Each session lives on disk under `<DataRoot>/sessions/<id>/` as `info.cb` (metadata) plus `log.bin` (length-prefixed CbObject log records). On startup the service scans that directory and loads prior sessions as ended sessions, preloading the tail of each log so historical views work after a restart. `SessionLog` is noexcept-constructed and falls back to a disabled state on disk errors, so a bad disk can't take down `RegisterSession`. `GetSession` falls back to the ended-sessions list (fixes historical log fetches over HTTP). `LoadTail` counts only successfully-parsed records. **Pruning.** Periodic cleanup task drops ended sessions once any of three caps is exceeded: age (default 1 year), count (default 1000), or total on-disk footprint (default 50 MiB). Runs 30 s after startup, hourly thereafter. Active sessions never pruned; disk removal and directory stat happen outside the exclusive lock so a slow filesystem can't stall lookups. **Client liveness.** Sessions carry a `ProcessHandle` for the client-reported pid, captured at registration time so Windows pid recycling can't produce false positives. A 30 s asio timer probes liveness and ends dead sessions through the normal remove path, producing a synthetic `Session ended: process exited (...)` line persisted to `log.bin`. Windows decodes common NTSTATUS exit codes to human names (Ctrl-C, access violation, stack overflow, ...); POSIX stays at plain `process exited`. Clients auto-fill `ClientPid` only for local targets (unix socket / loopback); the server defensively accepts pids only from `IsLocalMachineRequest()` peers. zenserver also reports its own pid when registering its self-session, so it shows up with a real pid in the dashboard and `zen sessions ls`. **Synthetic end-of-session line.** `RemoveSession` takes an optional reason; before the session moves to the ended list it appends an Info-level `Session ended[: reason]` entry through the normal log path (released outside `m_Lock`). Current reasons: `client request` (HTTP DELETE), `server shutdown` (self-session), `process exited (...)` (liveness). **UE_LOGFMT structured entries.** `POST /sessions/{id}/log` now accepts `{level, logger, format, fields}` alongside the existing `{level, logger, message}` shape. New `logtemplate.{h,cpp}` implements UE's `StructuredLog.cpp` template grammar (field paths with `.name` / `[N]`, `{{`/`}}` escapes, `$text` / `$format` / `$locformat` object conventions, bounded recursion). Renders to a displayable message at intake while persisting raw format + fields so a future UI can drill into fields without another schema bump. Hot path is zero-alloc — renders into `ExtendableStringBuilder<256>` using stack-buffered `Oid::ToString` / `IoHash::ToHexString` overloads. UI shows a `{…}` marker with the raw template + JSON-pretty fields on hover. **Parent sessions.** `SessionInfo` gains `parent_session_id`; hub-managed storage server child processes inherit the hub's session id via `--parent-session=<id>`. `ZEN_SESSIONS_URL` env var becomes a fallback for `--sessions-url` / config when neither is provided. The in-process session log sink is disabled when a remote sessions target is configured (logs flow through `SessionsServiceClient` instead). The sessions UI groups child sessions under their parent (collapsible/expandable, sorts as a unit, supports nesting). **Platform reporting.** `SessionInfo` gains `Platform`, flowed end-to-end: client auto-fills via `GetRuntimePlatformName()`, server persists in `info.cb` (`plat`) and emits on GET. UI renders as a SimpleIcons-style inline SVG (windows / macOS / iOS / linux / wine / android / playstation / xbox / nintendo) with case-insensitive alias resolution (Win32/Win64, PS4/PS5, XSX/XSS, NintendoSwitch, iPhone/iPad, Darwin/OSX). Unknown values fall back to text; sorting runs on the underlying string. **WebSocket log streaming.** Sessions UI moves from 2 s polling to a WebSocket push model. New `WsSubscriber` has a stable id + helper methods. UI caps the log-line DOM at 5 000 entries with a shared cursor-regression helper, factored out of two call sites. Per-broadcast allocations trimmed on the push path; fixed a stack overrun in the WS log broadcast hex-id buffer. **Log memory.** `LogEntry::Level` is now `logging::LogLevel` (1 byte) instead of `std::string` (~32 B) — saves ~310 KB per full 10 k-entry deque and eliminates a per-message allocation in the in-proc sink. On-disk format writes an int32 and accepts either int or legacy string on read. `LogEntry` strings now live in a `MemoryArena`; logger names are interned across the deque. `SessionLog::Append` and `WriteSessionInfoFile` drop their `UniqueBuffer` round-trip and write `CbObject::GetView()` straight through `BasicFile` / `SafeWriteFile`. Multi-entry `POST /log` batched under one lock + one push. **In-proc log timestamps.** `InProcSessionLogSink::TimePointToDateTime` previously preserved only whole seconds, so every in-proc entry rendered at `.000` ms in the dashboard and `zen sessions tail`. It now adds the sub-second part (nanoseconds → 100 ns ticks) to keep ms precision end-to-end. **UI.** Side "Session Details" panel is gone — its info is inline in the table (appname, mode, platform, id, timestamps, this/log pills, active dot). Bottom panel is a tabbed `Log | Metadata` view with a right-side "Session Information" panel beside metadata; log-only controls (filter, newest-first, follow, log-level filter, expand/collapse) hide when Metadata is active, polling keeps running across tab switches. Wide-mode toggle fills the viewport edge-to-edge. Log lines show the logger category; timestamps render in 24 h with zero-padded fields regardless of locale. Sessions list defaults to All / 10 per page / created-desc, gains click-to-sort headers on the full dataset, a header filter box, and a pager aligned to the table's right edge. Duplicate auto-injected `<h1>Sessions</h1>` removed. ## `zen sessions` CLI New command tree on the `zen` client for inspecting the sessions service from the terminal: - **`zen sessions ls`** — lists sessions (active first, ended next; newest-first within each group) with id, status, app/mode, pid, created, duration, and log count. Supports `--status active|ended|all` (default `all`). - **`zen sessions status`** — prints the sessions service summary: self id, active / ended counts, and the read/write/delete/list/request/bad-request counters from `/stats/sessions`. - **`zen sessions tail [session]`** — tails a session's log. With no argument it tails zenserver's own session (resolved via `/sessions/list`'s `self_id`); an explicit 24-hex id targets any session, including ended ones (historical replay). `--lines N` (default 50, 0 = all buffered) trims the initial dump client-side. `--follow` prefers a WebSocket push subscription on `/sessions/ws` for sub-second latency; on upgrade failure (older server, blocked port, unix-socket transport) it falls back to HTTP cursor polling at `--interval-ms` (default 500), with sleeps chunked to 50 ms so Ctrl-C reacts quickly. Output matches `zen::logging::FullFormatter` (`[YY-MM-DD HH:MM:SS.mmm] [lvl] [logger] message`); on a TTY the level is colored and the logger is bold, with continuation lines indented under the message column using the *visible* prefix width. 404 surfaces as `(session ended)` and connection errors as `(server gone)` — both clean exits, so stopping the server mid-tail no longer prints a stack trace. - **`zen sessions ui`** — opens `<host>/dashboard/?page=sessions` in the user's default browser. Rejects unix-socket hosts. A small `ZenServiceClient::IsUnixSocket()` helper now wraps the unix-socket check used by `ui`, `sessions tail` (WS path), and `sessions ui`. ## Logging `BacklogSink` captures early-startup log entries in a fixed-capacity ring so late-attached sinks (session sink, file sink) can replay them. Detaches from the broadcast list when disabled; backed by destructor-only cleanup (no `unique_ptr` indirection per entry). Tuned defaults so the backlog covers typical bring-up without unbounded growth. ## `zen trace serve` viewer - Compact timeline mode for high-density views. - New `TRACE_INT_VALUE` / `TRACE_FLOAT_VALUE` counter trace points + a counters page in the viewer. - Callsite tables collapsed into a single tabbed panel. - Lossless `Oid <-> Guid` bridge for trace session ids; trace `SessionId` plumbed through. - `tourist` parser hardening: bounds-check `BufferStream::read`, validate `Type::info_size` before `patch()`, convert `parse_important_aux` to a loop (avoids deep recursion), widen `ParserPool` index to `uint32`, bounds-check field offsets in the dispatcher, pin `Types::parse` buffer up-front. ## `MemoryArena` Configurable chunk size, inline chunk list, oversize requests routed to truly-dedicated chunks (no slack waste, no fragmentation when one allocation is much larger than the chunk). ## Allocation cleanups across hot paths - `zenhttp::HttpRequestRouter::HandleRequest` and `FormatPackageMessageInternal`: drop heap allocations. - Compact-binary validation: `eastl::fixed_vector` + `eastl::sort`; eliminate `std::vector` churn. - `zenserverprocess`: trim transient allocations in spawn paths. - Sessions HTTP intake / broadcast: drop transient `std::string` allocs.
Diffstat (limited to 'src/zen/frontend/html/timeline.js')
-rw-r--r--src/zen/frontend/html/timeline.js170
1 files changed, 111 insertions, 59 deletions
diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js
index f463a8418..e0fd64181 100644
--- a/src/zen/frontend/html/timeline.js
+++ b/src/zen/frontend/html/timeline.js
@@ -2,16 +2,35 @@
// Canvas-drawn flame graph with per-thread swimlanes, pan+zoom, hover
// tooltip, click-to-select and scope-name highlighting.
-const HEADER_H = 18; // thread name row height
-const DEPTH_H = 16; // scope lane row height
const MAX_DRAWN_DEPTH = 32;
-const MIN_RECT_W = 1.5; // don't draw narrower than this (px)
-const RULER_H = 20;
-const THREAD_GAP = 6;
-const PADDING_X = 0;
-const REGION_LANE_H = 18; // region band row height
-const REGION_HEADER_H = 16; // category header row height
-const REGIONS_GAP = 6; // gap between the region rack and the first thread
+const MIN_RECT_W = 1.5; // don't draw narrower than this (px)
+const RULER_H = 20;
+const THREAD_GAP = 6;
+const PADDING_X = 0;
+const REGIONS_GAP = 6; // gap between the region rack and the first thread
+
+// Per-mode row metrics. Compact mode (toggled with 'c') shrinks every
+// vertical dimension so the flame graph fits many more threads/depths in
+// the viewport at the cost of in-bar text labels.
+const METRICS_NORMAL = {
+ headerH: 18, // thread name row height
+ depthH: 16, // scope lane row height
+ regionLaneH: 18, // region band row height
+ regionHeaderH: 16, // region category header row height
+ scopeFontPx: 11, // in-bar scope label font size
+ headerFontPx: 11, // thread header / region row font size
+ showLabels: true,
+};
+
+const METRICS_COMPACT = {
+ headerH: 12,
+ depthH: 4,
+ regionLaneH: 6,
+ regionHeaderH: 12,
+ scopeFontPx: 0, // labels too tall to fit; suppressed
+ headerFontPx: 9,
+ showLabels: false,
+};
// Scope colors: golden-angle hue rotation keyed on NameId so the same scope
// always renders in the same color across zoom levels.
@@ -69,6 +88,8 @@ export class Timeline {
this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us);
this.bookmarksVisible = true;
+ this.compact = false;
+ this.metrics = METRICS_NORMAL;
this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0);
// All categories enabled by default; renderRegionCategories() calls
// setEnabledRegionCategories() shortly after construction.
@@ -144,6 +165,17 @@ export class Timeline {
this.requestDraw();
}
+ setCompact(compact) {
+ const next = !!compact;
+ if (next === this.compact) return;
+ this.compact = next;
+ this.metrics = next ? METRICS_COMPACT : METRICS_NORMAL;
+ this.recomputeRegionsBlockH();
+ this.requestDraw();
+ }
+
+ toggleCompact() { this.setCompact(!this.compact); }
+
setEnabledRegionCategories(indices) {
this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices);
this.recomputeRegionsBlockH();
@@ -152,10 +184,11 @@ export class Timeline {
recomputeRegionsBlockH() {
this.regionsBlockH = 0;
+ const M = this.metrics || METRICS_NORMAL;
for (let i = 0; i < this.regionCategories.length; i++) {
if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue;
const cat = this.regionCategories[i];
- this.regionsBlockH += REGION_HEADER_H + cat.lane_count * REGION_LANE_H;
+ this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH;
}
if (this.regionsBlockH > 0) {
this.regionsBlockH += REGIONS_GAP;
@@ -406,6 +439,7 @@ export class Timeline {
if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id;
return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true });
});
+ const M = this.metrics;
for (const threadId of sorted) {
const timeline = this.timelines.get(threadId);
const scopes = timeline ? timeline.scopes : [];
@@ -414,8 +448,8 @@ export class Timeline {
if (s[3] > maxDepth) maxDepth = s[3];
}
if (maxDepth > MAX_DRAWN_DEPTH) maxDepth = MAX_DRAWN_DEPTH;
- const rowH = HEADER_H + (maxDepth + 1) * DEPTH_H;
- rows.push({ threadId, y, headerH: HEADER_H, maxDepth, height: rowH });
+ const rowH = M.headerH + (maxDepth + 1) * M.depthH;
+ rows.push({ threadId, y, headerH: M.headerH, maxDepth, height: rowH });
y += rowH + THREAD_GAP;
}
// y now points at the bottom of the last row in scrolled coords.
@@ -467,6 +501,7 @@ export class Timeline {
const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9";
const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e";
+ const M = this.metrics;
for (const row of rows) {
if (row.y > H) break;
if (row.y + row.height < RULER_H) continue;
@@ -479,14 +514,14 @@ export class Timeline {
const prefix = isLane ? "⬦ " : "";
const label = `${prefix}${(meta && meta.name) || `tid ${row.threadId}`} · ${row.threadId}`;
ctx.fillStyle = isLane ? "rgba(180, 140, 255, 0.8)" : fg2;
- ctx.font = "11px -apple-system, Segoe UI, sans-serif";
+ ctx.font = `${M.headerFontPx}px -apple-system, Segoe UI, sans-serif`;
ctx.textBaseline = "middle";
ctx.fillText(label, 6, row.y + row.headerH / 2);
// Swimlane backgrounds
for (let d = 0; d <= row.maxDepth; d++) {
ctx.fillStyle = d % 2 === 0 ? "rgba(255,255,255,0.015)" : "rgba(255,255,255,0.00)";
- ctx.fillRect(0, row.y + row.headerH + d * DEPTH_H, W, DEPTH_H);
+ ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH);
}
const timeline = this.timelines.get(row.threadId);
@@ -554,6 +589,7 @@ export class Timeline {
drawRegions(ctx, W) {
if (this.regionCategories.length === 0) return;
+ const M = this.metrics;
const startUs = this.startUs;
const endUs = this.endUs;
const pxPerUs = this.pxPerUs();
@@ -566,13 +602,15 @@ export class Timeline {
const cat = this.regionCategories[ci];
// Category header
ctx.fillStyle = bg1;
- ctx.fillRect(0, catY, W, REGION_HEADER_H);
- ctx.fillStyle = fg2;
- ctx.font = "10px -apple-system, Segoe UI, sans-serif";
- ctx.textBaseline = "middle";
- ctx.textAlign = "left";
- ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + REGION_HEADER_H / 2);
- catY += REGION_HEADER_H;
+ ctx.fillRect(0, catY, W, M.regionHeaderH);
+ if (M.showLabels) {
+ ctx.fillStyle = fg2;
+ ctx.font = "10px -apple-system, Segoe UI, sans-serif";
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "left";
+ ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + M.regionHeaderH / 2);
+ }
+ catY += M.regionHeaderH;
// Region bands for this category
for (const r of cat.regions) {
@@ -585,35 +623,35 @@ export class Timeline {
const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs);
if (w < MIN_RECT_W) continue;
- const y = catY + r.depth * REGION_LANE_H;
+ const y = catY + r.depth * M.regionLaneH;
ctx.fillStyle = regionFillColor(r.name);
- ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2);
+ ctx.fillRect(x, y + 1, w, M.regionLaneH - 2);
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 1;
- ctx.strokeRect(x + 0.5, y + 1.5, w - 1, REGION_LANE_H - 3);
+ ctx.strokeRect(x + 0.5, y + 1.5, w - 1, M.regionLaneH - 3);
const visX = Math.max(x, 0);
const visRight = Math.min(x + w, this.width);
const visW = visRight - visX;
- if (visW > 24 && r.name) {
+ if (M.showLabels && visW > 24 && r.name) {
ctx.fillStyle = "rgba(255,255,255,0.95)";
ctx.font = "11px -apple-system, Segoe UI, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.save();
ctx.beginPath();
- ctx.rect(visX + 3, y, visW - 6, REGION_LANE_H);
+ ctx.rect(visX + 3, y, visW - 6, M.regionLaneH);
ctx.clip();
- ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2);
+ ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2);
ctx.restore();
}
- this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name });
+ this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name });
}
- catY += cat.lane_count * REGION_LANE_H;
+ catY += cat.lane_count * M.regionLaneH;
}
}
@@ -660,6 +698,8 @@ export class Timeline {
drawScopes(ctx, timeline, row, pxPerUs, textColor) {
const { scopes, perDepth } = timeline;
+ const M = this.metrics;
+ const depthH = M.depthH;
const startUs = this.startUs;
const endUs = this.endUs;
@@ -669,7 +709,9 @@ export class Timeline {
ctx.textBaseline = "middle";
ctx.textAlign = "left";
- ctx.font = "11px -apple-system, Segoe UI, sans-serif";
+ if (M.showLabels) {
+ ctx.font = `${M.scopeFontPx}px -apple-system, Segoe UI, sans-serif`;
+ }
const rowTop = row.y + row.headerH;
const maxDepth = Math.min(row.maxDepth, perDepth.length - 1);
@@ -695,7 +737,11 @@ export class Timeline {
}
}
- const y = rowTop + depth * DEPTH_H;
+ const y = rowTop + depth * depthH;
+ // Compact mode shrinks the bar to the row height; normal mode
+ // leaves a 1px gap top and bottom for readability.
+ const barTop = M.showLabels ? y + 1 : y;
+ const barH = M.showLabels ? depthH - 2 : depthH;
let rendered = 0;
for (let j = lo; j < indices.length; j++) {
const s = scopes[indices[j]];
@@ -718,39 +764,44 @@ export class Timeline {
ctx.fillStyle = isHighlighted
? `hsl(${hue.toFixed(0)}, 50%, 50%)`
: `hsl(${hue.toFixed(0)}, 30%, 35%)`;
- ctx.fillRect(x, y + 1, w, DEPTH_H - 2);
- ctx.strokeStyle = "rgba(255,255,255,0.25)";
- ctx.lineWidth = 1;
- ctx.setLineDash([2, 2]);
- ctx.beginPath();
- ctx.moveTo(x, y + 1.5);
- ctx.lineTo(x + w, y + 1.5);
- ctx.stroke();
- ctx.setLineDash([]);
+ ctx.fillRect(x, barTop, w, barH);
+ if (M.showLabels) {
+ ctx.strokeStyle = "rgba(255,255,255,0.25)";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath();
+ ctx.moveTo(x, barTop + 0.5);
+ ctx.lineTo(x + w, barTop + 0.5);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
} else {
ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId);
- ctx.fillRect(x, y + 1, w, DEPTH_H - 2);
+ ctx.fillRect(x, barTop, w, barH);
}
// Draw the label pinned to the visible portion of the rect
- // so zooming into a long scope still shows its name.
- const visX = Math.max(x, 0);
- const visRight = Math.min(x + w, this.width);
- const visW = visRight - visX;
- if (visW > 30) {
- const name = this.model.scopeNames[nameId] || "?";
- const maxChars = Math.floor((visW - 6) / 6);
- const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name;
- ctx.fillStyle = "rgba(255,255,255,0.95)";
- ctx.save();
- ctx.beginPath();
- ctx.rect(visX + 3, y, visW - 6, DEPTH_H);
- ctx.clip();
- ctx.fillText(shown, visX + 4, y + DEPTH_H / 2);
- ctx.restore();
+ // so zooming into a long scope still shows its name. Skipped
+ // in compact mode where the bar is too short for readable text.
+ if (M.showLabels) {
+ const visX = Math.max(x, 0);
+ const visRight = Math.min(x + w, this.width);
+ const visW = visRight - visX;
+ if (visW > 30) {
+ const name = this.model.scopeNames[nameId] || "?";
+ const maxChars = Math.floor((visW - 6) / 6);
+ const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name;
+ ctx.fillStyle = "rgba(255,255,255,0.95)";
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(visX + 3, y, visW - 6, depthH);
+ ctx.clip();
+ ctx.fillText(shown, visX + 4, y + depthH / 2);
+ ctx.restore();
+ }
}
- this.hits.push({ x, y, w, h: DEPTH_H - 2, threadId: row.threadId, tuple: s });
+ this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s });
}
}
}
@@ -764,12 +815,13 @@ export class Timeline {
const { rows } = this.layoutThreads();
const row = rows.find((r) => r.threadId === this.selected.threadId);
if (!row) return;
+ const M = this.metrics;
const x = this.xAtUs(beginUs);
const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs());
- const y = row.y + row.headerH + depth * DEPTH_H;
+ const y = row.y + row.headerH + depth * M.depthH;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1.5;
- ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 1);
+ ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1);
}
drawViewportInfo() {