Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Data view: a session's joint traces (intent against actual) #527
smith/arm:smith/521-data-view-a-session-s-joint-traces-inten → main 1 commit fork
6 files
changed +173
−5
Software/so101/src/precision-history.test.ts +33
| @@ -115,6 +115,11 @@ describe("GET /precision on the arm server", () => { | ||
| 115 | 115 | expect(((await (await fetch(`${base}/precision?hours=336`)).json()) as Scorecard).hours).toHaveLength(1); |
| 116 | 116 | expect(after.session).toBeNull(); |
| 117 | 117 | const [row] = after.sessions; |
| 118 | const kept = (await (await fetch(`${base}/precision/trace?id=${row!.id}`)).json()) as { joints: Record<string, unknown[]>; events: Array<{ kind: string }> }; | |
| 119 | expect(kept.joints["1"]!.length).toBeGreaterThan(0); | |
| 120 | expect(kept.events.map((e) => e.kind)).toContain("STOP"); | |
| 121 | expect((await fetch(`${base}/precision/trace?id=x`)).status).toBe(400); | |
| 122 | expect((await fetch(`${base}/precision/trace?id=99999`)).status).toBe(404); | |
| 118 | 123 | expect(row).toMatchObject({ commit: "feed123", sessions: 1 }); |
| 119 | 124 | const causes = after.window.snapBacks.byCause; |
| 120 | 125 | expect(causes.cap).toBe(1); |
| @@ -179,3 +184,31 @@ describe("GET /precision on the arm server", () => { | ||
| 179 | 184 | expect(after.sessions.at(-1)).toMatchObject({ commit: "feed123", releases: 1 }); |
| 180 | 185 | }); |
| 181 | 186 | }); |
| 187 | ||
| 188 | describe("the session trace", () => { | |
| 189 | test("kept at 5 Hz with the marks, stored with the session, and dropped from the oldest beyond the newest 50", () => { | |
| 190 | let now = Date.UTC(2026, 9, 22, 10, 0); | |
| 191 | const history = new PrecisionHistory({ commit: "abc1234", now: () => now }); | |
| 192 | const t = history.begin(); | |
| 193 | t.target(1, 2048, now); | |
| 194 | for (let ms = 0; ms < 1000; ms += 20) t.sample(1, 2000 + ms / 10, now + ms); // 50 read-backs in a second | |
| 195 | t.release(now + 500); | |
| 196 | t.snapBack("jam", now + 600, 1, 30); | |
| 197 | now += 1000; | |
| 198 | history.end(t); | |
| 199 | const id = history.rows("sessions")[0]!.id!; | |
| 200 | const trace = history.trace(id)!; | |
| 201 | expect(trace.joints[1]).toHaveLength(5); | |
| 202 | expect(trace.joints[1]![0]).toEqual([0, 2048, 2000]); | |
| 203 | expect(trace.events).toEqual([{ at: 500, kind: "release", id: null }, { at: 600, kind: "jam", id: 1 }]); | |
| 204 | expect(history.trace(id + 99)).toBeNull(); | |
| 205 | for (let i = 0; i < 50; i++) { | |
| 206 | const u = history.begin(); | |
| 207 | u.target(1, 2048, now); | |
| 208 | u.sample(1, 2000, now); | |
| 209 | history.end(u); | |
| 210 | } | |
| 211 | expect(history.trace(id)).toBeNull(); | |
| 212 | expect(history.trace(id + 50)).not.toBeNull(); | |
| 213 | }); | |
| 214 | }); |
Software/so101/src/precision-history.ts +18 −5
| @@ -8,7 +8,7 @@ import { openDb, type Db } from "./sqlite.ts"; | ||
| 8 | 8 | import { existsSync, readFileSync } from "node:fs"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import type { PExperiment } from "./tuning-run.ts"; |
| 11 | import { emptyRecords, summarize, trim, Tracker, WINDOW_MS, type Card, type Cause } from "./precision.ts"; | |
| 11 | import { emptyRecords, summarize, trim, Tracker, WINDOW_MS, type Card, type Cause, type Trace } from "./precision.ts"; | |
| 12 | 12 | |
| 13 | 13 | /** The deployed commit: the forge's or CI's variable when set, else the checkout's HEAD, else "unknown". Never a shell. */ |
| 14 | 14 | export function deployedCommit(env: Record<string, string | undefined> = process.env, root = join(import.meta.dirname, "../../.."), tree = join(import.meta.dirname, "..")): string { |
| @@ -34,7 +34,12 @@ export function deployedCommit(env: Record<string, string | undefined> = process | ||
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | /** One stored drive session, or one hour of them: the headline numbers. */ |
| 37 | /** How many of the newest sessions keep their traces; older rows keep the card alone. */ | |
| 38 | export const TRACES_KEPT = 50; | |
| 39 | ||
| 37 | 40 | export interface Row { |
| 41 | /** The stored session's id (what GET /precision/trace asks for); hourly rows have none. */ | |
| 42 | id?: number; | |
| 38 | 43 | at: string; |
| 39 | 44 | commit: string; |
| 40 | 45 | sessions: number; |
| @@ -66,8 +71,8 @@ export interface Scorecard { | ||
| 66 | 71 | hours: Row[]; |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | type Stored = { at: string; commit: string; sessions: number; seconds: number; tracking_p95_deg: number | null; tracking_max_deg: number | null; arrive_p95_ms: number | null; snap_backs: number; releases: number; latency_p95_ms: number | null; worst_joint: string | null }; | |
| 70 | const fromStored = (r: Stored): Row => ({ at: r.at, commit: r.commit, sessions: r.sessions, seconds: r.seconds, trackingP95Deg: r.tracking_p95_deg, trackingMaxDeg: r.tracking_max_deg, arriveP95Ms: r.arrive_p95_ms, snapBacks: r.snap_backs, releases: r.releases, latencyP95Ms: r.latency_p95_ms, worstJoint: r.worst_joint }); | |
| 74 | type Stored = { id?: number; at: string; commit: string; sessions: number; seconds: number; tracking_p95_deg: number | null; tracking_max_deg: number | null; arrive_p95_ms: number | null; snap_backs: number; releases: number; latency_p95_ms: number | null; worst_joint: string | null }; | |
| 75 | const fromStored = (r: Stored): Row => ({ ...(r.id === undefined ? {} : { id: r.id }), at: r.at, commit: r.commit, sessions: r.sessions, seconds: r.seconds, trackingP95Deg: r.tracking_p95_deg, trackingMaxDeg: r.tracking_max_deg, arriveP95Ms: r.arrive_p95_ms, snapBacks: r.snap_backs, releases: r.releases, latencyP95Ms: r.latency_p95_ms, worstJoint: r.worst_joint }); | |
| 71 | 76 | |
| 72 | 77 | export class PrecisionHistory { |
| 73 | 78 | readonly commit: string; |
| @@ -86,6 +91,7 @@ export class PrecisionHistory { | ||
| 86 | 91 | const columns = `commit_id TEXT NOT NULL, sessions INTEGER NOT NULL, seconds REAL NOT NULL, tracking_p95_deg REAL, tracking_max_deg REAL, arrive_p95_ms REAL, |
| 87 | 92 | snap_backs INTEGER NOT NULL, releases INTEGER NOT NULL, latency_p95_ms REAL, worst_joint TEXT`; |
| 88 | 93 | this..run(`CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, at TEXT NOT NULL, ${columns}, card TEXT NOT NULL)`); |
| 94 | if (!(this..query("PRAGMA table_info(sessions)").all() as Array<{ name: string }>).some((c) => c.name === "trace")) this..run("ALTER TABLE sessions ADD COLUMN trace TEXT"); | |
| 89 | 95 | this..run("CREATE TABLE IF NOT EXISTS tunings (id INTEGER PRIMARY KEY, commit_id TEXT NOT NULL, result TEXT NOT NULL)"); |
| 90 | 96 | // An hour's roll-up keeps the worst of its sessions' p95s: one bad session in the hour is what the trend must show. |
| 91 | 97 | this..run(`CREATE TABLE IF NOT EXISTS hours (at TEXT NOT NULL, ${columns}, PRIMARY KEY (at, commit_id))`); |
| @@ -125,7 +131,8 @@ export class PrecisionHistory { | ||
| 125 | 131 | const maxDeg = card.joints.reduce<number | null>((m, j) => (j.errorDeg.max === null ? m : Math.max(m ?? 0, j.errorDeg.max)), null); |
| 126 | 132 | const row = [this.commit, 1, seconds, card.trackingP95Deg, maxDeg, card.arriveP95Ms, card.snapBacks.total, card.releases, card.latencyP95Ms, card.worstJoint] as const; |
| 127 | 133 | const iso = new Date(at).toISOString(); |
| 128 | this..query(`INSERT INTO sessions (at, commit_id, sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint, card) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(iso, ...row, JSON.stringify(card)); | |
| 134 | const { lastInsertRowid } = this..query(`INSERT INTO sessions (at, commit_id, sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint, card, trace) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(iso, ...row, JSON.stringify(card), JSON.stringify(tracker.trace())); | |
| 135 | this..query("UPDATE sessions SET trace = NULL WHERE trace IS NOT NULL AND id <= ?").run(Number(lastInsertRowid) - TRACES_KEPT); | |
| 129 | 136 | const hour = `${iso.slice(0, 13)}:00:00.000Z`; |
| 130 | 137 | this. |
| 131 | 138 | .query( |
| @@ -146,12 +153,18 @@ export class PrecisionHistory { | ||
| 146 | 153 | rows(table: "sessions" | "hours", limit = 48): Row[] { |
| 147 | 154 | const sql = |
| 148 | 155 | table === "sessions" |
| 149 | ? `SELECT at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM sessions ORDER BY id DESC LIMIT ?` | |
| 156 | ? `SELECT id, at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM sessions ORDER BY id DESC LIMIT ?` | |
| 150 | 157 | : `SELECT at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM hours ORDER BY at DESC LIMIT ?`; |
| 151 | 158 | const rows = this..query(sql).all(limit) as Stored[]; |
| 152 | 159 | return rows.reverse().map(fromStored); |
| 153 | 160 | } |
| 154 | 161 | |
| 162 | /** A stored session's traces and marks, or null when the session is unknown or old enough to have lost them. */ | |
| 163 | trace(id: number): Trace | null { | |
| 164 | const row = this..query("SELECT trace FROM sessions WHERE id = ?").get(id) as { trace: string | null } | null; | |
| 165 | return row?.trace ? (JSON.parse(row.trace) as Trace) : null; | |
| 166 | } | |
| 167 | ||
| 155 | 168 | /** Keep one finished P experiment. */ |
| 156 | 169 | addTuning(result: PExperiment): void { |
| 157 | 170 | this..query("INSERT INTO tunings (commit_id, result) VALUES (?, ?)").run(this.commit, JSON.stringify(result)); |
Software/so101/src/precision.ts +26
| @@ -188,10 +188,22 @@ interface Joint { | ||
| 188 | 188 | written?: { at: number; from: number }; |
| 189 | 189 | } |
| 190 | 190 | |
| 191 | /** The trace keeps a point per joint at most this often (5 Hz), and at most TRACE_POINTS of them (ten minutes of a drive). */ | |
| 192 | export const TRACE_STEP_MS = 200; | |
| 193 | export const TRACE_POINTS = 3000; | |
| 194 | /** One trace point: ms since the session began, the driver's target, the read-back; both in ticks. */ | |
| 195 | export type TracePoint = readonly [number, number, number]; | |
| 196 | /** A session's decimated traces by joint id, and the event marks to draw on the same axis (ms since the session began). */ | |
| 197 | export interface Trace { | |
| 198 | joints: Record<number, TracePoint[]>; | |
| 199 | events: Array<{ at: number; kind: "release" | Cause; id: number | null }>; | |
| 200 | } | |
| 201 | ||
| 191 | 202 | /** One drive session's measurements; every record also goes to the sinks (the rolling window). */ |
| 192 | 203 | export class Tracker { |
| 193 | 204 | readonly startedAt: number; |
| 194 | 205 | readonly records = emptyRecords(); |
| 206 | readonly = new Map<number, TracePoint[]>(); | |
| 195 | 207 | readonly : Records[]; |
| 196 | 208 | readonly = new Map<number, Joint>(); |
| 197 | 209 | |
| @@ -252,6 +264,10 @@ export class Tracker { | ||
| 252 | 264 | if (j.target === undefined) return; |
| 253 | 265 | const error = j.target - present; |
| 254 | 266 | this.(id, "error", at, Math.abs(error)); |
| 267 | let trace = this..get(id); | |
| 268 | if (!trace) this..set(id, (trace = [])); | |
| 269 | const rel = at - this.startedAt; | |
| 270 | if (trace.length < TRACE_POINTS && (!trace.length || rel - trace[trace.length - 1]![0] >= TRACE_STEP_MS)) trace.push([rel, j.target, present]); | |
| 255 | 271 | if (j.pending && j.pending.from === null) { |
| 256 | 272 | // The first read-back: already there is no chase (it cannot be timed); otherwise the chase is on from here. |
| 257 | 273 | if (Math.abs(error) <= ARRIVE_TICKS) j.pending = undefined; |
| @@ -324,6 +340,16 @@ export class Tracker { | ||
| 324 | 340 | for (const [jid, j] of this.) if (id === undefined || id === jid) (j.target = undefined), (j.pending = undefined), (j.settling = undefined); |
| 325 | 341 | } |
| 326 | 342 | |
| 343 | /** The decimated traces and the marks (releases, snap-backs by cause) so far. */ | |
| 344 | trace(): Trace { | |
| 345 | const rel = (at: number) => Math.max(0, at - this.startedAt); | |
| 346 | const events: Trace["events"] = [ | |
| 347 | ...this.records.releases.map((at) => ({ at: rel(at), kind: "release" as const, id: null })), | |
| 348 | ...this.records.snaps.map((s) => ({ at: rel(s.at), kind: s.cause, id: s.id })), | |
| 349 | ].sort((a, b) => a.at - b.at); | |
| 350 | return { joints: Object.fromEntries(this.), events }; | |
| 351 | } | |
| 352 | ||
| 327 | 353 | /** Ends any settle watch still open, so the last arrival's overshoot counts. */ |
| 328 | 354 | finish(at: number): void { |
| 329 | 355 | for (const [id, j] of this.) if (j.settling) this.(id, j, at); |
Software/so101/src/server.ts +8
| @@ -43,6 +43,7 @@ | ||
| 43 | 43 | * POST /tuning/p {"joint": id, "p": 1-64} run the P experiment (src/tuning-run.ts) on that joint while nobody drives: sweep, write, sweep, keep or put back; 202 at once, |
| 44 | 44 | * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings |
| 45 | 45 | * GET /tuning the run's phase while one is under way, and the last outcome |
| 46 | * GET /precision/trace?id=N a stored session's decimated joint traces (5 Hz, target against read-back) and event marks; the id is on its row in /precision; 404 once older than the newest 50 | |
| 46 | 47 | * POST /precision/snap-back {"cause": one of CAUSES, "sizeDeg"}: a snap-back only the page saw, counted beside the server's own (400 for any other cause) |
| 47 | 48 | * GET /model.json the arm model: joints, the model's floor (floorZ) and the enforced table (tableZ), where the URDF is served |
| 48 | 49 | * GET /leader-map.js the leader calibration mapping, for the browser (src/leader-map.js) |
| @@ -890,6 +891,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 890 | 891 | if (!/^\d{1,3}$/.test(hours) || Number(hours) < 1 || Number(hours) > 720) throw new HttpError(400, "hours must be 1-720"); |
| 891 | 892 | return json(res, 200, precision.scorecard(Number(hours))); |
| 892 | 893 | } |
| 894 | case "GET /precision/trace": { | |
| 895 | const id = url.searchParams.get("id") ?? ""; | |
| 896 | if (!/^\d{1,9}$/.test(id)) throw new HttpError(400, "id is a stored session's id, from GET /precision"); | |
| 897 | const trace = precision.trace(Number(id)); | |
| 898 | if (!trace) throw new HttpError(404, "no trace kept for that session"); | |
| 899 | return json(res, 200, trace); | |
| 900 | } | |
| 893 | 901 | case "POST /precision/snap-back": { |
| 894 | 902 | // What only the page sees (a model or slider moved off the driver's target, a reconnect): counted beside the server's own. |
| 895 | 903 | const snap = parseSnapBack(await readBody(req)); |
Software/so101/src/trace-chart.test.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { readTrace, traceSvg } from "./trace-chart.ts"; | |
| 3 | ||
| 4 | describe("the trace chart", () => { | |
| 5 | const trace = { joints: { 1: [[0, 2048, 2000], [200, 2100, 2060], [400, 2100, 2100]] }, events: [{ at: 300, kind: "jam", id: 1 }, { at: 350, kind: "release", id: null }, { at: 100, kind: "cap", id: 2 }] }; | |
| 6 | ||
| 7 | test("draws target against read-back with only this joint's marks", () => { | |
| 8 | const svg = traceSvg(readTrace(trace)!, 1); | |
| 9 | expect(svg).toContain("trace-intent"); | |
| 10 | expect(svg).toContain("trace-actual"); | |
| 11 | expect(svg.match(/trace-mark/g)).toHaveLength(2); | |
| 12 | expect(traceSvg(readTrace(trace)!, 3)).toContain("No trace kept"); | |
| 13 | }); | |
| 14 | ||
| 15 | test("reads an answer field by field: junk is dropped, markup never gets through", () => { | |
| 16 | expect(readTrace("x")).toBeNull(); | |
| 17 | expect(readTrace({ joints: {}, events: [{ at: 1, kind: '"><script>', id: null }, { at: "1", kind: "jam" }] })!.events).toEqual([]); | |
| 18 | const messy = readTrace({ joints: { 1: [[0, 1, 2], [0, "a", 2], [1, NaN, 2]], x: [[0, 1, 2]] }, events: [] })!; | |
| 19 | expect(messy.joints[1]).toHaveLength(1); | |
| 20 | expect(Object.keys(messy.joints)).toEqual(["1"]); | |
| 21 | }); | |
| 22 | }); |
Software/so101/src/trace-chart.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * A session's joint trace drawn: the driver's target (dashed) against the arm's read-back (solid) for one joint, with | |
| 3 | * the event marks (releases, snap-backs by cause) as vertical lines on the same time axis. The trace is the arm | |
| 4 | * server's GET /precision/trace, read field by field (it comes over the network); the output is an SVG string in | |
| 5 | * which every number is a number and every word is escaped. No script. | |
| 6 | */ | |
| 7 | ||
| 8 | import { CAUSES, isCause, type Trace, type TracePoint } from "./precision.ts"; | |
| 9 | import { JOINTS } from "./sts3215.ts"; | |
| 10 | ||
| 11 | const TICKS_PER_DEGREE = 4096 / 360; | |
| 12 | const MAX_POINTS = 3000; | |
| 13 | const MAX_EVENTS = 500; | |
| 14 | ||
| 15 | const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); | |
| 16 | const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); | |
| 17 | ||
| 18 | /** The trace from whatever was answered, bounded and checked; null when it was not a trace. */ | |
| 19 | export function readTrace(body: unknown): Trace | null { | |
| 20 | if (typeof body !== "object" || body === null || Array.isArray(body)) return null; | |
| 21 | const { joints, events } = body as Record<string, unknown>; | |
| 22 | if (typeof joints !== "object" || joints === null || !Array.isArray(events)) return null; | |
| 23 | const out: Trace = { joints: {}, events: [] }; | |
| 24 | for (const [key, list] of Object.entries(joints)) { | |
| 25 | const id = Number(key); | |
| 26 | if (!Number.isInteger(id) || id < 0 || id > 253 || !Array.isArray(list)) continue; | |
| 27 | out.joints[id] = list.slice(0, MAX_POINTS).filter((p): p is TracePoint => Array.isArray(p) && p.length === 3 && p.every(finite)); | |
| 28 | } | |
| 29 | for (const e of events.slice(0, MAX_EVENTS)) { | |
| 30 | const { at, kind, id } = (e ?? {}) as Record<string, unknown>; | |
| 31 | if (finite(at) && (kind === "release" || isCause(kind))) out.events.push({ at, kind, id: Number.isInteger(id) ? (id as number) : null }); | |
| 32 | } | |
| 33 | return out; | |
| 34 | } | |
| 35 | ||
| 36 | const WIDTH = 640; | |
| 37 | const HEIGHT = 200; | |
| 38 | const PAD = { l: 44, r: 8, t: 8, b: 20 }; | |
| 39 | const n = (v: number) => Number(v.toFixed(1)); | |
| 40 | ||
| 41 | /** One joint's target against read-back, in degrees over seconds, with the session's marks. Empty trace: a one-line note. */ | |
| 42 | export function traceSvg(trace: Trace, id: number): string { | |
| 43 | const points = trace.joints[id] ?? []; | |
| 44 | const name = JOINTS[id] ?? `joint ${id}`; | |
| 45 | if (!points.length) return `<p class="trace-empty">No trace kept for ${escape(name)}.</p>`; | |
| 46 | const end = Math.max(points[points.length - 1]![0], ...trace.events.map((e) => e.at), 1); | |
| 47 | const deg = points.flatMap(([, a, b]) => [a / TICKS_PER_DEGREE, b / TICKS_PER_DEGREE]); | |
| 48 | let lo = Math.min(...deg), hi = Math.max(...deg); | |
| 49 | if (hi - lo < 1) (lo -= 0.5), (hi += 0.5); | |
| 50 | const x = (ms: number) => n(PAD.l + (ms / end) * (WIDTH - PAD.l - PAD.r)); | |
| 51 | const y = (d: number) => n(HEIGHT - PAD.b - ((d - lo) / (hi - lo)) * (HEIGHT - PAD.t - PAD.b)); | |
| 52 | const line = (pick: 1 | 2) => points.map((p, i) => `${i ? "L" : "M"}${x(p[0])} ${y(p[pick] / TICKS_PER_DEGREE)}`).join(""); | |
| 53 | const marks = trace.events | |
| 54 | .filter((e) => e.id === null || e.id === id) | |
| 55 | .map((e) => `<line class="trace-mark" data-kind="${escape(e.kind)}" x1="${x(e.at)}" x2="${x(e.at)}" y1="${PAD.t}" y2="${HEIGHT - PAD.b}"><title>${escape(e.kind)} at ${n(e.at / 1000)} s</title></line>`) | |
| 56 | .join(""); | |
| 57 | return ( | |
| 58 | `<svg class="trace" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-label="${escape(name)}: target against read-back, degrees over ${n(end / 1000)} seconds">` + | |
| 59 | `<text x="${PAD.l - 4}" y="${PAD.t + 8}" text-anchor="end">${n(hi)}°</text><text x="${PAD.l - 4}" y="${HEIGHT - PAD.b}" text-anchor="end">${n(lo)}°</text>` + | |
| 60 | `<text x="${WIDTH - PAD.r}" y="${HEIGHT - 4}" text-anchor="end">${n(end / 1000)} s</text>` + | |
| 61 | `${marks}<path class="trace-intent" fill="none" stroke="currentColor" stroke-dasharray="4 3" d="${line(1)}"/><path class="trace-actual" fill="none" stroke="currentColor" d="${line(2)}"/></svg>` | |
| 62 | ); | |
| 63 | } | |
| 64 | ||
| 65 | /** The kinds a mark can have, for a legend. */ | |
| 66 | export const MARK_KINDS: readonly string[] = ["release", ...CAUSES]; |
new above
offline