From 4b76473fdec44d0907add7472b352d017eca11b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 23:13:19 -0400 Subject: [PATCH] Site: order only blocks whose projections overlap; ordering tests --- site/package.json | 3 +- site/src/iso.ts | 58 +++++++++++++++++++++------------ site/test/order.test.ts | 29 +++++++++++++++++ site/video/demo/make.py | 31 ++++++++++-------- site/video/record/doc.sh | 12 +++++-- site/video/record/lib.sh | 10 ++++++ site/video/record/table.sh | 66 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 site/test/order.test.ts create mode 100644 site/video/record/table.sh diff --git a/site/package.json b/site/package.json index 1a5758f..4311812 100644 --- a/site/package.json +++ b/site/package.json @@ -6,7 +6,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "deploy": "pnpm build && wrangler deploy" + "deploy": "pnpm build && wrangler deploy", + "test": "node --test test/*.test.ts" }, "devDependencies": { "typescript": "6.0.3", diff --git a/site/src/iso.ts b/site/src/iso.ts index 003a6ad..14f7a68 100644 --- a/site/src/iso.ts +++ b/site/src/iso.ts @@ -49,10 +49,24 @@ export function block(spec: Block): SVGGElement { return g; } -const boxes = new WeakMap(); +const boxes = new WeakMap>(); + +/** A box's screen footprint on three iso axes. */ +function spans(b: Required): [number, number][] { + return [ + [b.x - b.z - b.h, b.x + b.w - b.z], + [b.y - b.z - b.h, b.y + b.d - b.z], + [b.x - b.y - b.d, b.x + b.w - b.y], + ]; +} + +function overlap(a: Required, b: Required): boolean { + const [sa, sb] = [spans(a), spans(b)]; + return sa.every(([lo, hi], i) => lo < sb[i][1] && sb[i][0] < hi); +} /** Whether `a` draws before `b`: the first separating axis decides. */ -function behind(a: Block & { z: number }, b: Block & { z: number }): boolean { +function behind(a: Required, b: Required): boolean { if (a.x + a.w <= b.x) return true; if (b.x + b.w <= a.x) return false; if (a.y + a.d <= b.y) return true; @@ -62,19 +76,18 @@ function behind(a: Block & { z: number }, b: Block & { z: number }): boolean { throw new Error("blocks intersect"); } -/** Blocks in drawing order, back to front. */ -function ordered(blocks: SVGGElement[]): SVGGElement[] { - const box = (g: SVGGElement) => { - const found = boxes.get(g); - if (!found) throw new Error("not a block"); - return found; - }; - const left = [...blocks]; - const out: SVGGElement[] = []; - while (left.length) { - const next = left.findIndex((g) => left.every((o) => o === g || !behind(box(o), box(g)))); - if (next < 0) throw new Error("blocks overlap; no drawing order"); - out.push(...left.splice(next, 1)); +/** Drawing order, back to front; only overlapping boxes constrain. */ +export function order(list: Required[]): number[] { + const before = list.map((b, i) => + list.flatMap((a, j) => (j !== i && overlap(a, b) && behind(a, b) ? [j] : [])), + ); + const done = new Set(); + const out: number[] = []; + while (out.length < list.length) { + const next = list.findIndex((_, i) => !done.has(i) && before[i].every((j) => done.has(j))); + if (next < 0) throw new Error("blocks occlude each other in a cycle"); + done.add(next); + out.push(next); } return out; } @@ -82,13 +95,18 @@ function ordered(blocks: SVGGElement[]): SVGGElement[] { /** An SVG for `blocks`, drawn back to front. */ export function stage(blocks: SVGGElement[]): SVGSVGElement { const svg = document.createElementNS(SVG, "svg"); - svg.append(...ordered(blocks)); + const box = (g: SVGGElement) => { + const found = boxes.get(g); + if (!found) throw new Error("not a block"); + return found; + }; + svg.append(...order(blocks.map(box)).map((i) => blocks[i])); document.body.append(svg); - const box = svg.getBBox(); + const bounds = svg.getBBox(); svg.remove(); - svg.setAttribute("viewBox", `${box.x} ${box.y} ${box.width} ${box.height}`); - svg.setAttribute("width", String(Math.ceil(box.width))); - svg.setAttribute("height", String(Math.ceil(box.height))); + svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + svg.setAttribute("width", String(Math.ceil(bounds.width))); + svg.setAttribute("height", String(Math.ceil(bounds.height))); svg.setAttribute("class", "stage"); return svg; } diff --git a/site/test/order.test.ts b/site/test/order.test.ts new file mode 100644 index 0000000..2deca19 --- /dev/null +++ b/site/test/order.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { order } from "../src/iso.ts"; + +const box = (x: number, y: number, z: number, w: number, d: number, h: number) => ({ x, y, z, w, d, h, tone: "" }); +const desk = box(0, 0, 0, 12, 12, 0.5); + +test("blocks apart on screen add no constraint, so no false cycle", () => { + const list = [desk, box(0, 2, 0.5, 1, 1, 1), box(1, 0, 0.5, 2, 1, 1), box(0, 1, 0.5, 2, 1, 1)]; + const drawn = order(list); + assert.equal(drawn[0], 0, "the desk is drawn first"); + // C (index 3) covers A (index 1) only from behind A. + assert.ok(drawn.indexOf(3) < drawn.indexOf(1)); +}); + +test("the desk scene draws ground, then blocks", () => { + const list = [desk, box(2, 2, 0.5, 3, 3, 4), box(7, 2, 0.5, 3, 3, 2), box(2, 7, 0.5, 3, 3, 1)]; + assert.equal(order(list)[0], 0); +}); + +test("a block in front draws after the one it covers", () => { + const back = box(0, 0, 0, 2, 2, 4); + const front = box(2, 2, 0, 2, 2, 1); + assert.deepEqual(order([front, back]), [1, 0]); +}); + +test("intersecting blocks fail loudly", () => { + assert.throws(() => order([box(0, 0, 0, 2, 2, 2), box(1, 1, 1, 2, 2, 2)]), /intersect/); +}); diff --git a/site/video/demo/make.py b/site/video/demo/make.py index 98ff117..2891878 100644 --- a/site/video/demo/make.py +++ b/site/video/demo/make.py @@ -122,10 +122,15 @@ def docx(path): # XLSX: half-year sales by product line. -MONTHS = ["January", "February", "March", "April", "May", "June"] -LINES = ["Trail Maps", "Guidebooks", "Prints", "Workshops"] -SALES = [[4820, 6150, 2310, 1800], [5140, 5980, 2475, 2100], [6380, 7210, 2890, 2650], - [7120, 8040, 3120, 3400], [7950, 8820, 3460, 3900], [8610, 9470, 3780, 4350]] +MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] +SALES = { + "Trail Maps": [4820, 5140, 6380, 7120, 7950, 8610], + "Guidebooks": [6150, 5980, 7210, 8040, 8820, 9470], + "Prints": [2310, 2475, 2890, 3120, 3460, 3780], + "Workshops": [1800, 2100, 2650, 3400, 3900, 4350], + "Postcards": [940, 870, 1120, 1360, 1580, 1710], + "Gift Cards": [1250, 980, 760, 820, 1430, 2050], +} def col(i): @@ -139,18 +144,18 @@ def xlsx(path): def num(ref, value, s): return f'{value}' - rows = ['' + text("A1", "Month", 1) - + "".join(text(f"{col(i + 1)}1", n, 2) for i, n in enumerate(LINES)) + text("F1", "Total", 2) + ""] - for r, (month, values) in enumerate(zip(MONTHS, SALES), start=2): - cells = text(f"A{r}", month, 3) + "".join(num(f"{col(i + 1)}{r}", v, 4) for i, v in enumerate(values)) - total = sum(values) - cells += f'SUM(B{r}:E{r}){total}' + rows = ['' + text("A1", "Product", 1) + + "".join(text(f"{col(i + 1)}1", m, 2) for i, m in enumerate(MONTHS)) + text("H1", "Total", 2) + ""] + for r, (line, values) in enumerate(SALES.items(), start=2): + cells = text(f"A{r}", line, 3) + "".join(num(f"{col(i + 1)}{r}", v, 4) for i, v in enumerate(values)) + cells += f'SUM(B{r}:G{r}){sum(values)}' rows.append(f'{cells}') - rows.append('' + text("A8", "Half year", 1) + "") + rows.append('' + text("A9", "All lines", 1) + + "".join(f'' for i in range(7)) + "") sheet = (f'' - '' + '' '' - '' + '' f'{"".join(rows)}') styles = (f'' '' diff --git a/site/video/record/doc.sh b/site/video/record/doc.sh index a8bbb09..ea67fcd 100644 --- a/site/video/record/doc.sh +++ b/site/video/record/doc.sh @@ -8,23 +8,29 @@ wait_log "lok: first tile drawn" 60 wait_log "doc: 3 headings" front sleep 3 +read -r zx zy < <(where "title=zoom-in") +record_start doc snap doc-before # The page's left edge, past the outline column. page=$(first_bright doc-before 600 252 225) x=$((win_x + page)) y=$win_y -read -r zx zy < <(where "title=zoom-in") -record_start doc -pause 1.0 +pause 0.6 +mark "click end of Purpose paragraph" mouse click $((x + 720)) $((y + 392)) pause 0.5 +mark "typing starts" typed " Every route also gets a printable offline map." +mark "typing ends" pause 0.8 +mark "double-click 'forty'" mouse double $((x + 243)) $((y + 351)) pause 0.5 keys 'keystroke "b" using command down' +mark "bold applied" pause 1.0 mouse click "$zx" "$zy" +mark "zoom to 125%" pause 0.6 mouse move $((x + 760)) $((y + 640)) 0.7 pause 2.0 diff --git a/site/video/record/lib.sh b/site/video/record/lib.sh index 967705e..09baa70 100644 --- a/site/video/record/lib.sh +++ b/site/video/record/lib.sh @@ -146,13 +146,23 @@ record_start() { read -r x y w h < <(frame) [ "$w" = "$win_w" ] && [ "$h" = "$win_h" ] || echo "note: window is ${w}x${h}, not ${win_w}x${win_h}" >&2 rm -f "$out/rec/$1.mov" + cap_name=$1 ffmpeg -hide_banner -loglevel error -f avfoundation -capture_cursor 1 -framerate 60 -pixel_format nv12 -i "0:none" \ -vf "crop=$((w * 2)):$((h * 2)):$((x * 2)):$((y * 2))" -c:v h264_videotoolbox -b:v 80M -r 60 \ "$out/rec/$1.mov" "$out/rec/$1.txt" sleep 1.2 } +now() { perl -MTime::HiRes=time -e 'printf "%.2f\n", time'; } + +# Notes a key moment, in clip seconds, for the editor. +mark() { + awk -v a="$(now)" -v b="$t0" -v m="$1" 'BEGIN { printf "%5.1f %s\n", a - b - 0.5, m }' >>"$out/rec/$cap_name.txt" +} + # Stops capture; encodes 1920x1080 H.264 60 fps; checks frames. record_stop() { local name=$1 diff --git a/site/video/record/table.sh b/site/video/record/table.sh new file mode 100644 index 0000000..b6dfef5 --- /dev/null +++ b/site/video/record/table.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# ElyTable: SUM row, sort by total, insert a chart. +source "$(dirname "$0")/lib.sh" + +us_input +launch ely-table "$demo/Sales 2027.xlsx" +wait_log "lok: first tile drawn" 60 +front +sleep 4 +x=$win_x +y=$win_y +# Cell centers in window points. +cell() { echo $((x + $1)) $((y + $2)); } +menu() { + osascript -e "tell application \"System Events\" to tell (first process whose unix id is $app_pid) to click menu item \"$2\" of menu \"$1\" of menu bar 1" >/dev/null +} +record_start table +pause 0.8 +mark "click B9" +mouse click $(cell 242 357) +pause 0.4 +mark "typing =SUM(B2:B7)" +typed "=SUM(B2:B7)" +pause 0.4 +keys "key code 36" +mark "formula entered" +pause 0.5 +mouse click $(cell 242 357) +pause 0.4 +mark "fill handle drag B9 to H9" +mouse drag $(cell 295 370) $(cell 880 362) 1.0 +pause 0.8 +mark "select A1:H7" +mouse drag $(cell 118 145) $(cell 880 305) 0.8 +pause 0.4 +menu Data AutoFilter +mark "AutoFilter on" +pause 0.7 +mouse click $(cell 921 147) +mark "Total filter menu open" +pause 0.8 +read -r sx sy < <(where $'title=Sort Z to A\t') +mouse click "$sx" "$sy" +mark "sorted Z to A by Total" +pause 1.2 +mouse click $(cell 1100 600) +pause 0.3 +mark "select A1:G7" +mouse drag $(cell 118 145) $(cell 774 305) 0.8 +pause 0.4 +menu Insert "Chart…" +mark "Insert Chart dialog" +pause 0.8 +read -r ix iy < <(where $'AXButton\tvalue=\ttitle=Insert\t') +mouse click $((ix - 87)) $((iy - 185)) +typed "Sales by product" +pause 0.4 +mouse click "$ix" "$iy" +mark "chart inserted" +pause 1.0 +mouse drag $(cell 520 300) $(cell 520 610) 0.9 +mark "chart moved below the table" +pause 0.6 +mouse click $(cell 1100 700) +pause 1.5 +record_stop table