paseo-plugin-genui 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/OVERVIEW.md +21 -0
- package/README.md +77 -2
- package/assets/fonts/Excalifont-OFL.txt +109 -0
- package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/screenshot-chat.png +0 -0
- package/assets/screenshot-gallery.png +0 -0
- package/assets/screenshot-playground.png +0 -0
- package/client/components/content.tsx +468 -0
- package/client/components/data.tsx +967 -0
- package/client/components/dev.tsx +998 -0
- package/client/components/diagram.tsx +513 -0
- package/client/components/inputs.tsx +994 -0
- package/client/components/layout.tsx +239 -0
- package/client/components/media.tsx +428 -0
- package/client/components/project.tsx +641 -0
- package/client/components/widgets.tsx +836 -0
- package/client/gallery.tsx +449 -0
- package/client/live-ui.tsx +525 -0
- package/client/loaders.ts +342 -0
- package/client/markdown.tsx +172 -0
- package/client/node-renderer.tsx +149 -0
- package/client/render.tsx +186 -0
- package/client/runtime.ts +103 -0
- package/client/settings.tsx +57 -0
- package/client/web.ts +186 -0
- package/gallery/Acceptance-baseline.ui +1 -0
- package/gallery/Acceptance-checklist.ui +14 -0
- package/gallery/Acceptance-evidence.ui +9 -0
- package/gallery/Acceptance.ui +1 -0
- package/gallery/Badge.ui +3 -0
- package/gallery/Benchmark-context.ui +8 -0
- package/gallery/Benchmark-hyperfine.ui +1 -0
- package/gallery/Benchmark-runs-list.ui +9 -0
- package/gallery/Benchmark-runs.ui +1 -0
- package/gallery/Benchmark-vitest.ui +1 -0
- package/gallery/Benchmark.ui +9 -0
- package/gallery/Board-bb-tasks.ui +1 -0
- package/gallery/Board-gh-issues.ui +1 -0
- package/gallery/Board-gh-prs.ui +1 -0
- package/gallery/Board.ui +12 -0
- package/gallery/Button-destructive.ui +4 -0
- package/gallery/Button.ui +5 -0
- package/gallery/Calendar.ui +15 -0
- package/gallery/CalendarHeatmap-short.ui +11 -0
- package/gallery/CalendarHeatmap.ui +4 -0
- package/gallery/Callout.ui +4 -0
- package/gallery/Card.ui +4 -0
- package/gallery/Carousel.ui +8 -0
- package/gallery/Changes-paths.ui +1 -0
- package/gallery/Changes-uncommitted.ui +1 -0
- package/gallery/Changes.ui +1 -0
- package/gallery/Chart-area.ui +3 -0
- package/gallery/Chart-funnel.ui +3 -0
- package/gallery/Chart-heatmap.ui +11 -0
- package/gallery/Chart-pie.ui +3 -0
- package/gallery/Chart-radar.ui +3 -0
- package/gallery/Chart-scatter.ui +4 -0
- package/gallery/Chart-stacked.ui +5 -0
- package/gallery/Chart.ui +3 -0
- package/gallery/Checklist.ui +6 -0
- package/gallery/Choice.ui +8 -0
- package/gallery/Code.ui +3 -0
- package/gallery/Commits-limit.ui +1 -0
- package/gallery/Commits.ui +1 -0
- package/gallery/Compare-conditions.ui +4 -0
- package/gallery/Compare-three.ui +7 -0
- package/gallery/Compare.ui +4 -0
- package/gallery/Coverage-go.ui +1 -0
- package/gallery/Coverage-lcov.ui +1 -0
- package/gallery/Coverage-python.ui +1 -0
- package/gallery/Coverage.ui +1 -0
- package/gallery/DateInput.ui +5 -0
- package/gallery/Decision-percent.ui +10 -0
- package/gallery/Decision.ui +12 -0
- package/gallery/Dependencies-audit.ui +1 -0
- package/gallery/Dependencies-both.ui +1 -0
- package/gallery/Dependencies-list.ui +7 -0
- package/gallery/Dependencies-pip.ui +1 -0
- package/gallery/Dependencies-pnpm.ui +1 -0
- package/gallery/Dependencies.ui +1 -0
- package/gallery/Details.ui +4 -0
- package/gallery/Diagram-flow.ui +16 -0
- package/gallery/Diagram-sequence.ui +15 -0
- package/gallery/Diagram.ui +16 -0
- package/gallery/Diff.ui +3 -0
- package/gallery/Divider.ui +5 -0
- package/gallery/EditableTable.ui +9 -0
- package/gallery/Entity-list.ui +6 -0
- package/gallery/Entity.ui +8 -0
- package/gallery/Environments.ui +1 -0
- package/gallery/Estimate-points.ui +6 -0
- package/gallery/Estimate.ui +8 -0
- package/gallery/File.ui +4 -0
- package/gallery/Findings-biome.ui +1 -0
- package/gallery/Findings-eslint.ui +1 -0
- package/gallery/Findings-oxlint.ui +1 -0
- package/gallery/Findings-review.ui +1 -0
- package/gallery/Findings-ruff.ui +1 -0
- package/gallery/Findings-sarif.ui +1 -0
- package/gallery/Findings-ship.ui +11 -0
- package/gallery/Findings-tsc.ui +1 -0
- package/gallery/Findings.ui +7 -0
- package/gallery/Flashcards.ui +3 -0
- package/gallery/FollowUps.ui +4 -0
- package/gallery/Form.ui +8 -0
- package/gallery/Gallery-links.ui +8 -0
- package/gallery/Gallery-strip.ui +14 -0
- package/gallery/Gallery.ui +10 -0
- package/gallery/Gauge.ui +7 -0
- package/gallery/Grid.ui +8 -0
- package/gallery/Hotspots-photo.ui +8 -0
- package/gallery/Hotspots.ui +11 -0
- package/gallery/Icon.ui +5 -0
- package/gallery/Image.ui +4 -0
- package/gallery/ImageSearch-aspects.ui +6 -0
- package/gallery/ImageSearch.ui +4 -0
- package/gallery/Input.ui +5 -0
- package/gallery/Json-api.ui +1 -0
- package/gallery/Json.ui +8 -0
- package/gallery/KeyValue.ui +3 -0
- package/gallery/List-dots.ui +8 -0
- package/gallery/List.ui +8 -0
- package/gallery/ListEditor.ui +5 -0
- package/gallery/Logs-jsonl.ui +1 -0
- package/gallery/Logs-lines.ui +11 -0
- package/gallery/Logs-python.ui +1 -0
- package/gallery/Logs.ui +1 -0
- package/gallery/Map-city.ui +8 -0
- package/gallery/Map.ui +8 -0
- package/gallery/Metric.ui +3 -0
- package/gallery/OptionCards-multi.ui +10 -0
- package/gallery/OptionCards.ui +10 -0
- package/gallery/Pipeline-checks.ui +1 -0
- package/gallery/Pipeline-passed.ui +1 -0
- package/gallery/Pipeline-running.ui +1 -0
- package/gallery/Pipeline-stages.ui +13 -0
- package/gallery/Pipeline.ui +1 -0
- package/gallery/Plan.ui +9 -0
- package/gallery/Progress.ui +4 -0
- package/gallery/Provenance-deploy.ui +6 -0
- package/gallery/Provenance-no-receipt.ui +1 -0
- package/gallery/Provenance.ui +1 -0
- package/gallery/PullRequest.ui +1 -0
- package/gallery/Quiz.ui +6 -0
- package/gallery/Radio.ui +5 -0
- package/gallery/Rating.ui +5 -0
- package/gallery/Roadmap-sprint.ui +8 -0
- package/gallery/Roadmap-year.ui +9 -0
- package/gallery/Roadmap.ui +12 -0
- package/gallery/Row.ui +3 -0
- package/gallery/SegmentedBar-status.ui +8 -0
- package/gallery/SegmentedBar.ui +8 -0
- package/gallery/Select.ui +5 -0
- package/gallery/Slider.ui +7 -0
- package/gallery/Snippet.ui +3 -0
- package/gallery/Sources-chips.ui +4 -0
- package/gallery/Sources.ui +8 -0
- package/gallery/Sparkline.ui +6 -0
- package/gallery/Stack.ui +7 -0
- package/gallery/StackTrace-aggregate.ui +1 -0
- package/gallery/StackTrace-browser.ui +1 -0
- package/gallery/StackTrace-go.ui +1 -0
- package/gallery/StackTrace-java.ui +1 -0
- package/gallery/StackTrace-python.ui +1 -0
- package/gallery/StackTrace-rust.ui +1 -0
- package/gallery/StackTrace.ui +1 -0
- package/gallery/Status.ui +9 -0
- package/gallery/Stepper.ui +5 -0
- package/gallery/Steps.ui +8 -0
- package/gallery/Swatches-long-names.ui +4 -0
- package/gallery/Swatches-picker.ui +5 -0
- package/gallery/Swatches.ui +10 -0
- package/gallery/Table.ui +7 -0
- package/gallery/Tabs.ui +8 -0
- package/gallery/Tests-junit.ui +1 -0
- package/gallery/Tests-vitest-junit.ui +1 -0
- package/gallery/Tests-vitest.ui +1 -0
- package/gallery/Tests.ui +9 -0
- package/gallery/Text.ui +4 -0
- package/gallery/TextArea.ui +5 -0
- package/gallery/Threads-ids.ui +1 -0
- package/gallery/Threads.ui +1 -0
- package/gallery/Timeline.ui +8 -0
- package/gallery/Timer.ui +4 -0
- package/gallery/Toggle.ui +5 -0
- package/gallery/Transcript-text.ui +1 -0
- package/gallery/Transcript-untimed.ui +1 -0
- package/gallery/Transcript-whisper.ui +1 -0
- package/gallery/Transcript.ui +1 -0
- package/gallery/Tree.ui +3 -0
- package/gallery/Treemap-du.ui +1 -0
- package/gallery/Treemap-esbuild.ui +1 -0
- package/gallery/Treemap-tree.ui +7 -0
- package/gallery/Treemap.ui +18 -0
- package/gallery/Walkthrough.ui +9 -0
- package/gallery/Waterfall-har.ui +1 -0
- package/gallery/Waterfall-trace.ui +1 -0
- package/gallery/Waterfall.ui +11 -0
- package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
- package/gallery/data/acceptance/coil-gate.json +153 -0
- package/gallery/data/acceptance/orbit-baseline.json +163 -0
- package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
- package/gallery/data/acceptance/shots/gate-light.png +0 -0
- package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
- package/gallery/data/activity.json +1 -0
- package/gallery/data/bb-tasks-orb.json +420 -0
- package/gallery/data/biome.json +229 -0
- package/gallery/data/compare/status-after.png +0 -0
- package/gallery/data/compare/status-before.png +0 -0
- package/gallery/data/compare/status-dark-2x.png +0 -0
- package/gallery/data/compare/status-rerender.png +0 -0
- package/gallery/data/coverage-py.json +1 -0
- package/gallery/data/coverage-summary.json +1 -0
- package/gallery/data/coverage.out +23 -0
- package/gallery/data/deploys.json +1 -0
- package/gallery/data/device-runs.json +285 -0
- package/gallery/data/du-node-modules.txt +359 -0
- package/gallery/data/esbuild-meta.json +292 -0
- package/gallery/data/eslint.json +266 -0
- package/gallery/data/eslint.sarif +343 -0
- package/gallery/data/findings-review.json +76 -0
- package/gallery/data/gh-issues-nextjs.json +293 -0
- package/gallery/data/gh-pr-checks.json +272 -0
- package/gallery/data/gh-prs-nextjs.json +419 -0
- package/gallery/data/gh-run-failed.json +1 -0
- package/gallery/data/gh-run-passed.json +1 -0
- package/gallery/data/gh-run-running.json +1 -0
- package/gallery/data/github-release.json +57 -0
- package/gallery/data/hyperfine-startup.json +187 -0
- package/gallery/data/lcov.info +234 -0
- package/gallery/data/logs/media-sync.log +127 -0
- package/gallery/data/logs/thumbnailer.jsonl +17 -0
- package/gallery/data/logs/vitest-run.log +89 -0
- package/gallery/data/npm-audit.json +672 -0
- package/gallery/data/npm-outdated.json +179 -0
- package/gallery/data/oxlint.json +156 -0
- package/gallery/data/pip-outdated.json +68 -0
- package/gallery/data/pnpm-outdated.json +58 -0
- package/gallery/data/provenance/devicectl-install.json +35 -0
- package/gallery/data/provenance/install-timeout.log +4 -0
- package/gallery/data/provenance/iphone-install.json +72 -0
- package/gallery/data/provenance/iphone-no-receipt.json +57 -0
- package/gallery/data/provenance/live-home.jpg +0 -0
- package/gallery/data/provenance/live-probe.txt +10 -0
- package/gallery/data/provenance/xcodebuild-2811.log +19 -0
- package/gallery/data/provenance/xcodebuild-4712.log +19 -0
- package/gallery/data/pytest-junit.xml +64 -0
- package/gallery/data/python-org-trace.json +35 -0
- package/gallery/data/python-org.har +31 -0
- package/gallery/data/retry.ts +13 -0
- package/gallery/data/ruff.json +261 -0
- package/gallery/data/src/durations.py +63 -0
- package/gallery/data/src/json-logic.ts +156 -0
- package/gallery/data/src/mapMath.ts +135 -0
- package/gallery/data/src/semver/bump.go +73 -0
- package/gallery/data/traces/browser-error.json +5 -0
- package/gallery/data/traces/go/main.go +27 -0
- package/gallery/data/traces/go-panic.txt +16 -0
- package/gallery/data/traces/java-caused-by.txt +15 -0
- package/gallery/data/traces/node-aggregate.txt +30 -0
- package/gallery/data/traces/node-cause.txt +21 -0
- package/gallery/data/traces/py/deploy.json +5 -0
- package/gallery/data/traces/py/deploy.py +17 -0
- package/gallery/data/traces/py/settings.py +19 -0
- package/gallery/data/traces/python-chain.txt +30 -0
- package/gallery/data/traces/rust/main.rs +11 -0
- package/gallery/data/traces/rust-panic.txt +16 -0
- package/gallery/data/traces/shop/cart.mjs +19 -0
- package/gallery/data/traces/shop/pricing.mjs +12 -0
- package/gallery/data/traces/shop/status.mjs +9 -0
- package/gallery/data/traces/web/app.js +11 -0
- package/gallery/data/traces/web/index.html +1 -0
- package/gallery/data/traces/web/search.js +10 -0
- package/gallery/data/transcript/call-noisy.wav +0 -0
- package/gallery/data/transcript/call.json +362 -0
- package/gallery/data/transcript/call.wav +0 -0
- package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
- package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
- package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
- package/gallery/data/tsc.txt +4 -0
- package/gallery/data/vitest-bench.json +119 -0
- package/gallery/data/vitest-junit.xml +133 -0
- package/gallery/data/vitest-report.json +612 -0
- package/gallery/images/bell.svg +10 -0
- package/gallery/images/bike.svg +47 -0
- package/gallery/images/footer-planet.webp +0 -0
- package/gallery/images/front-light.svg +13 -0
- package/gallery/images/golden-hour.webp +0 -0
- package/gallery/images/option-flowers.webp +0 -0
- package/gallery/images/option-hilltop.webp +0 -0
- package/gallery/images/option-meadow.webp +0 -0
- package/gallery/images/option-sky.webp +0 -0
- package/gallery/images/u-lock.svg +10 -0
- package/index.client.tsx +164 -0
- package/index.server.ts +216 -0
- package/package.json +50 -4
- package/paseo-plugin.json +14 -0
- package/server/data-source.ts +407 -0
- package/server/project.ts +981 -0
- package/server/web/address.ts +57 -0
- package/server/web/cache.ts +85 -0
- package/server/web/commons.ts +126 -0
- package/server/web/geocode.ts +40 -0
- package/server/web/image.ts +35 -0
- package/server/web/lookups.ts +248 -0
- package/server/web/net.ts +210 -0
- package/shared/core/acceptance.ts +744 -0
- package/shared/core/ast.ts +76 -0
- package/shared/core/benchmark.ts +743 -0
- package/shared/core/catalog.ts +733 -0
- package/shared/core/compare.ts +225 -0
- package/shared/core/coverage.ts +456 -0
- package/shared/core/data.ts +334 -0
- package/shared/core/decisions.ts +527 -0
- package/shared/core/dependencies.ts +604 -0
- package/shared/core/diagnostics.ts +24 -0
- package/shared/core/environments.ts +212 -0
- package/shared/core/evaluate.ts +737 -0
- package/shared/core/fields.ts +66 -0
- package/shared/core/findings.ts +1172 -0
- package/shared/core/form.ts +69 -0
- package/shared/core/index.ts +44 -0
- package/shared/core/junit.ts +252 -0
- package/shared/core/lexer.ts +275 -0
- package/shared/core/logs.ts +496 -0
- package/shared/core/markdown.ts +2374 -0
- package/shared/core/parser.ts +355 -0
- package/shared/core/perf-units.ts +228 -0
- package/shared/core/pipeline.ts +679 -0
- package/shared/core/project.ts +472 -0
- package/shared/core/provenance.ts +725 -0
- package/shared/core/stack-trace.ts +718 -0
- package/shared/core/streaming.ts +136 -0
- package/shared/core/test-results.ts +562 -0
- package/shared/core/thread-activity.ts +203 -0
- package/shared/core/tracking.ts +887 -0
- package/shared/core/transcript.ts +1364 -0
- package/shared/core/treemap.ts +446 -0
- package/shared/core/types.ts +70 -0
- package/shared/core/validate.ts +574 -0
- package/shared/core/waterfall.ts +536 -0
- package/shared/diagram/flowchart.ts +205 -0
- package/shared/diagram/layout.ts +440 -0
- package/shared/diagram/markdown.ts +135 -0
- package/shared/diagram/sequence.ts +122 -0
- package/shared/diagram/zoom.ts +114 -0
- package/shared/fallback.ts +120 -0
- package/shared/genui.test.ts +122 -0
- package/shared/instructions.ts +144 -0
- package/shared/logic/calendarMath.ts +130 -0
- package/shared/logic/chartMath.ts +289 -0
- package/shared/logic/choose-logic.ts +196 -0
- package/shared/logic/compare-logic.ts +270 -0
- package/shared/logic/content-model.ts +227 -0
- package/shared/logic/environments-logic.ts +53 -0
- package/shared/logic/explain-logic.ts +315 -0
- package/shared/logic/fieldLogic.ts +138 -0
- package/shared/logic/gallery-search.ts +71 -0
- package/shared/logic/icons.ts +322 -0
- package/shared/logic/json-logic.ts +153 -0
- package/shared/logic/mapMath.ts +133 -0
- package/shared/logic/project-logic.ts +39 -0
- package/shared/logic/references-model.ts +201 -0
- package/shared/logic/sketch.ts +165 -0
- package/shared/logic/supersession.ts +77 -0
- package/shared/logic/values.ts +133 -0
- package/shared/logic/widget-logic.ts +163 -0
- package/shared/rpc.ts +252 -0
- package/shared/segment.ts +81 -0
- package/shared/settings.ts +12 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIONS,
|
|
3
|
+
callSignature,
|
|
4
|
+
COMPONENTS,
|
|
5
|
+
componentSignature,
|
|
6
|
+
type ComponentGroup,
|
|
7
|
+
type ComponentName,
|
|
8
|
+
} from "./core/catalog";
|
|
9
|
+
|
|
10
|
+
function componentLines(groups: readonly ComponentGroup[]): string {
|
|
11
|
+
const lines = new Map<ComponentGroup, string[]>(groups.map((group) => [group, []]));
|
|
12
|
+
for (const name of Object.keys(COMPONENTS) as ComponentName[]) {
|
|
13
|
+
lines.get(COMPONENTS[name].group)?.push(componentSignature(name));
|
|
14
|
+
}
|
|
15
|
+
return [...lines].map(([group, entries]) => `${group}: ${entries.join(" · ")}`).join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const actionList = Object.entries(ACTIONS)
|
|
19
|
+
.map(([name, spec]) => callSignature(name, spec))
|
|
20
|
+
.join(" ");
|
|
21
|
+
|
|
22
|
+
export const REFERENCE_GROUPS: readonly ComponentGroup[] = ["Layout", "Content", "Data", "Project"];
|
|
23
|
+
export const REFERENCE_MORE_GROUPS: readonly ComponentGroup[] = ["Inputs", "Widgets", "Media", "Dev", "Manage"];
|
|
24
|
+
|
|
25
|
+
export const UI_REFERENCE = `# Inline UI reference
|
|
26
|
+
The \`\`\`ui language used by the Inline UI section below.
|
|
27
|
+
Syntax: one statement per line. \`$x = 8\` is state inputs bind to; \`name = expr\` names a value and \`f = (a, b) => expr\` a function; each bare expression renders. JS expressions incl. ** ?: ?? \`\${x}\` [lists] {objects} x.y map/filter/find/join/length, plus round(x, d), fmt(x, d), sum, min, max, abs, entries, Load(path). Named args go last: Slider($n, 0, 10, label: "N").
|
|
28
|
+
Components (? optional; tone: neutral|good|warn|bad|info if meaningful; more in the next section):
|
|
29
|
+
${componentLines(REFERENCE_GROUPS)}
|
|
30
|
+
Actions: ${actionList}; a list runs in order; Send posts as the user.
|
|
31
|
+
Data: Load("path") reads JSON/CSV/TSV/JSONL from the workspace, "$BB_THREAD_STORAGE/x.json" or /tmp into lists and objects, and other files (a log, tsc output) as text; never retype values a file or command output holds. Snippet and Image read files the same way.
|
|
32
|
+
Games, simulations, custom visuals: write \`\`\`app, a self-contained HTML page (inline CSS/JS, CSS vars --foreground --muted-foreground --border --primary --card; sandboxed, no network).`;
|
|
33
|
+
|
|
34
|
+
export const UI_REFERENCE_MORE = `# Inline UI reference, continued
|
|
35
|
+
${componentLines(REFERENCE_MORE_GROUPS)}
|
|
36
|
+
Icon names: Check X Plus Minus Edit Copy Trash2 Download ExternalLink Play Pause RotateCcw Search Settings GitBranch GitMerge GitPullRequest Bug Code Terminal FileText Folder Clock Calendar Mail Sent Lock Star Pin Link Eye AlertTriangle Info Globe Smartphone Laptop Cloud Layers Puzzle.`;
|
|
37
|
+
|
|
38
|
+
const MAP_EXAMPLE = `\`\`\`ui
|
|
39
|
+
Card("Day 1", [Map([{name: "Belém Tower, Lisbon", note: "Opens 9:30"}, {name: "Pena Palace, Sintra"}], route: true)])
|
|
40
|
+
\`\`\``;
|
|
41
|
+
|
|
42
|
+
export const CODE_WORK_INSTRUCTIONS = `# Inline UI for code work
|
|
43
|
+
Replies here are read in Paseo, which draws these components; pasted output, a markdown table or a plan in prose is the worse answer. Show what you ran with its component, from the file it saved (save as you run, in the format it reads, to $BB_THREAD_STORAGE or /tmp/opencode; /tmp reports load too); never paste or retype output. Tests or a build that fully passed need one sentence; coverage, timings, sizes, dependencies and findings always get their component.
|
|
44
|
+
- tests → Tests: \`vitest run --reporter=json --outputFile=$BB_THREAD_STORAGE/tests.json\`, \`jest --json --outputFile=…\`, JUnit XML
|
|
45
|
+
- a build, server or command log → Logs(Load(log), command, exit code): \`cmd > $BB_THREAD_STORAGE/build.log 2>&1\`
|
|
46
|
+
- an error with a stack → StackTrace(Load(…)) from \`cmd 2> $BB_THREAD_STORAGE/crash.txt\`, or StackTrace(the error text)
|
|
47
|
+
- lint, type errors, a code review → Findings: \`eslint -f json\`, \`tsc --noEmit --pretty false\`, SARIF, \`ruff check --output-format json\`; a review as [{severity, title, file, line, detail, fix}]
|
|
48
|
+
- coverage → Coverage(Load(…)): \`vitest run --coverage --coverage.reporter=lcov --coverage.reportsDirectory=$BB_THREAD_STORAGE/cov\`, then cov/lcov.info; or coverage-summary.json
|
|
49
|
+
- outdated or vulnerable packages → Dependencies: \`npm outdated --json --long\`, \`npm audit --json\`, \`pip list --outdated --format=json\`
|
|
50
|
+
- a CI run → Pipeline: \`gh run view <id> --json name,displayTitle,status,conclusion,event,headBranch,headSha,url,createdAt,updatedAt,jobs\`
|
|
51
|
+
- timings you measured (before/after, A vs B) → Benchmark([{name, before, after, unit}]) or Load(\`hyperfine --export-json\`); a page load → Waterfall(Load(a HAR)); what takes space → Treemap(Load(\`du -k -d 2 <dir>\` or an esbuild metafile))
|
|
52
|
+
- an API response → Json(Load(…)) from \`curl -s -o\`
|
|
53
|
+
- what you changed → Changes(); a commit → Changes(sha); this branch's commits → Commits(); the pull request → PullRequest(); subthreads → Threads()
|
|
54
|
+
- tasks or issues → Board(Load(…)) from \`gh issue list --json number,title,state,labels,assignees,url,updatedAt\`
|
|
55
|
+
- this project's worktrees and their threads → Environments()
|
|
56
|
+
- a plan for work not started (asked for one, or you want approval) → Plan(steps)
|
|
57
|
+
- a choice between options → Decision(options, criteria); how long work takes → Estimate(tasks); workstream health → Status(items); dated phases → Roadmap(items)
|
|
58
|
+
${componentLines(["Evidence"])}
|
|
59
|
+
Examples:
|
|
60
|
+
\`\`\`ui
|
|
61
|
+
Card("npm run build fails", [Logs(Load("$BB_THREAD_STORAGE/build.log"), "npm run build", 2), Findings(Load("$BB_THREAD_STORAGE/tsc.txt"))])
|
|
62
|
+
\`\`\`
|
|
63
|
+
\`\`\`ui
|
|
64
|
+
Card("Review: src/worker/queue.ts", [Findings([{severity: "high", title: "A retry drops the job's payload", file: "src/worker/queue.ts", line: 88, detail: "requeue() passes only the id", fix: "Pass the payload too"}])])
|
|
65
|
+
\`\`\`
|
|
66
|
+
\`\`\`ui
|
|
67
|
+
Card("Audit log for admin actions", [Plan([{title: "Append-only audit table", files: ["db/migrations/0012_audit.sql"], risk: "medium", estimate: "2h"}, {title: "Write an entry from each admin route", estimate: "3h"}])])
|
|
68
|
+
\`\`\`
|
|
69
|
+
\`\`\`ui
|
|
70
|
+
Card("Which queue?", [Decision([{name: "pg-boss", scores: [4, 5], pros: ["Uses our Postgres"]}, {name: "BullMQ", scores: [5, 2], cons: ["Needs Redis"]}], ["Throughput", "Ops cost"])])
|
|
71
|
+
\`\`\`
|
|
72
|
+
\`\`\`ui
|
|
73
|
+
Card("Dark mode", [Estimate([{title: "Theme tokens", low: 0.5, high: 1}, {title: "Audit every screen", low: 1, likely: 2, high: 4}], "d")])
|
|
74
|
+
\`\`\``;
|
|
75
|
+
|
|
76
|
+
export const AGENT_INSTRUCTIONS = `# Inline UI
|
|
77
|
+
This chat renders \`\`\`ui and \`\`\`app code blocks as live UI, and the user prefers them to markdown. Use them only in replies to the user, including after a task; never in progress notes, files, commits, PR text or tool input.
|
|
78
|
+
Write \`\`\`ui instead of a markdown table, a comparison of options, a plan (dated items on a Calendar, status or effort as Steps), a series of numbers (chart it), a list of changed files or commits (Changes(), Changes(sha), Commits(), PullRequest() read them from Paseo), or pasted output: save it to $BB_THREAD_STORAGE and Load it into its component (Inline UI for code work, above). For a calculation (how much, what if), make the user's numbers sliders and a list of items with amounts an EditableTable, and compute the result live. To let the user pick, give buttons; to ask more than one question, one Form; never a question tool or a numbered list. When you explain how something works (a flow, a request path, a lifecycle), put it in a \`\`\`ui block, even when asked to be brief: a Diagram of the flow, the steps as Steps({title, detail}) instead of a numbered list in prose, and Snippets of the key lines; file:line references go in the step details. When the user asks to see code or where something is, answer with a Snippet of those lines; to see an image file, an Image. The user never sees what your tools read: a file or image you opened is not on their screen, and pasted code drifts. Open the reply with one plain sentence that gives the answer (previews show only text); the UI carries the rest, so never restate it.
|
|
79
|
+
Match the content: a trip, places or a route → a Map of the stops, beside any day-by-day plan; a physical thing with parts → Hotspots on its image; a worked solution → Walkthrough; products or places → Entity cards in a Grid, the pages you read → Sources; activity per day → CalendarHeatmap; shares of a whole → SegmentedBar; a funnel → Chart("funnel"); colours → Swatches; picture options → OptionCards. Give a Diagram's key nodes details (text or a Snippet) so a click explains them. When the next steps are questions, end with FollowUps.
|
|
80
|
+
Wrap each UI in one Card. Examples:
|
|
81
|
+
\`\`\`ui
|
|
82
|
+
$seats = 10
|
|
83
|
+
$plan = "Pro"
|
|
84
|
+
price = $plan == "Pro" ? 29 : 12
|
|
85
|
+
Card("Estimate", [
|
|
86
|
+
Row([Choice($plan, ["Basic", "Pro"]), Slider($seats, 1, 50, label: "Seats")]),
|
|
87
|
+
Metric("Total", "$" + fmt(price * $seats)),
|
|
88
|
+
Button("Go with " + $plan, Send("Go with " + $plan), variant: "primary")
|
|
89
|
+
])
|
|
90
|
+
\`\`\`
|
|
91
|
+
\`\`\`ui
|
|
92
|
+
Card("How a login is handled", [
|
|
93
|
+
Diagram("flowchart LR\\n B[Browser] -->|POST /login| A[loginHandler]\\n A --> V{password ok?}\\n V -->|no| E[401]\\n V -->|yes| S[create session]"),
|
|
94
|
+
Steps([{title: "Rate limit", detail: "5 tries a minute per IP (login.ts:14)"}, {title: "Check the password", detail: "bcrypt compare; a miss returns 401 (login.ts:22)"}, {title: "Create the session", detail: "httpOnly cookie, 30 days (session.ts:8)"}]),
|
|
95
|
+
Snippet("src/auth/login.ts", 12, 34)
|
|
96
|
+
])
|
|
97
|
+
\`\`\`
|
|
98
|
+
\`\`\`ui
|
|
99
|
+
Card("Retries on 429", [Changes(), Tests(Load("$BB_THREAD_STORAGE/vitest.json"))])
|
|
100
|
+
\`\`\`
|
|
101
|
+
\`\`\`ui
|
|
102
|
+
Card("GET /repos/acme/api", [Json(Load("$BB_THREAD_STORAGE/repo.json"))])
|
|
103
|
+
\`\`\`
|
|
104
|
+
${MAP_EXAMPLE}
|
|
105
|
+
\`\`\`ui
|
|
106
|
+
Card("Launch plan", [Calendar([{date: "2026-10-14", title: "Draft issue 1"}, {date: "2026-10-20", title: "Send"}])])
|
|
107
|
+
\`\`\`
|
|
108
|
+
\`\`\`ui
|
|
109
|
+
$rows = [{item: "Pizza", price: 24}, {item: "Wine", price: 32}]
|
|
110
|
+
Card("Split the bill", [EditableTable($rows, ["item", "price"]), Metric("Each of 4", "€" + fmt(sum($rows.map(r => r.price)) / 4, 2))])
|
|
111
|
+
\`\`\`
|
|
112
|
+
\`\`\`ui
|
|
113
|
+
Card("Caching options", [
|
|
114
|
+
Table(["Option", "Speed"], [["In memory", Badge("fastest", tone: "good")], ["Redis", Badge("fast", tone: "good")]]),
|
|
115
|
+
Button("Use in memory"),
|
|
116
|
+
Button("Use Redis")
|
|
117
|
+
])
|
|
118
|
+
\`\`\`
|
|
119
|
+
\`\`\`ui
|
|
120
|
+
$channels = []
|
|
121
|
+
$notes = ""
|
|
122
|
+
Card("A few questions first", [Form([
|
|
123
|
+
Checklist($channels, ["Email", "Push"], label: "Which channels?"),
|
|
124
|
+
TextArea($notes, label: "Anything else?")
|
|
125
|
+
], submit: "Send answers")])
|
|
126
|
+
\`\`\`
|
|
127
|
+
Before you send a reply: any markdown table, pasted output or list of findings in it becomes \`\`\`ui.`;
|
|
128
|
+
|
|
129
|
+
export const WEB_LOOKUPS_OFF =
|
|
130
|
+
"Web lookups are off in this plugin's settings: ImageSearch, Map, Gallery {query} and https images won't load; use workspace files.";
|
|
131
|
+
|
|
132
|
+
export const AGENT_INSTRUCTIONS_WEB_OFF = AGENT_INSTRUCTIONS.replace(MAP_EXAMPLE, WEB_LOOKUPS_OFF);
|
|
133
|
+
|
|
134
|
+
export const GENUI_MARKER = "[paseo-plugin-genui]";
|
|
135
|
+
|
|
136
|
+
export function buildFullInstructions(webLookups: boolean): string {
|
|
137
|
+
return [
|
|
138
|
+
GENUI_MARKER,
|
|
139
|
+
UI_REFERENCE,
|
|
140
|
+
UI_REFERENCE_MORE,
|
|
141
|
+
CODE_WORK_INSTRUCTIONS,
|
|
142
|
+
webLookups ? AGENT_INSTRUCTIONS : AGENT_INSTRUCTIONS_WEB_OFF,
|
|
143
|
+
].join("\n\n");
|
|
144
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Calendar-heatmap arithmetic on whole days. A day is its number since
|
|
2
|
+
// 1970-01-01 in UTC, so `yyyy-mm-dd` strings map to days and back without any
|
|
3
|
+
// time zone ever moving one across midnight. No DOM: testable in node.
|
|
4
|
+
import { finiteNumber, isObject } from "./values";
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 86_400_000;
|
|
7
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
8
|
+
const DAY_FORMAT = new Intl.DateTimeFormat("en-US", {
|
|
9
|
+
weekday: "short",
|
|
10
|
+
month: "short",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
year: "numeric",
|
|
13
|
+
timeZone: "UTC",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** A real calendar date written `yyyy-mm-dd` as its day number; anything else is null. */
|
|
17
|
+
export function parseDay(value: unknown): number | null {
|
|
18
|
+
if (typeof value !== "string") return null;
|
|
19
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
|
20
|
+
if (match === null) return null;
|
|
21
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
22
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
23
|
+
// Date.UTC rolls 2026-02-30 over to March and maps years below 100 into the 1900s.
|
|
24
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
|
|
25
|
+
return date.getTime() / DAY_MS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `yyyy-mm-dd` of a day number. */
|
|
29
|
+
export function dayString(day: number): string {
|
|
30
|
+
return new Date(day * DAY_MS).toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "Tue, Mar 4, 2026". */
|
|
34
|
+
export function dayLabel(day: number): string {
|
|
35
|
+
return DAY_FORMAT.format(new Date(day * DAY_MS));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 0 for Sunday … 6 for Saturday; day 0 (1970-01-01) was a Thursday. */
|
|
39
|
+
export function weekday(day: number): number {
|
|
40
|
+
return (((day + 4) % 7) + 7) % 7;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CalendarDays {
|
|
44
|
+
/** Day number → value; repeated dates add up. */
|
|
45
|
+
readonly values: ReadonlyMap<number, number>;
|
|
46
|
+
/** Entries without a valid date or a number. */
|
|
47
|
+
readonly dropped: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `{yyyy-mm-dd: n}`, or a list of `{date, value}` (or `[date, value]`). A
|
|
52
|
+
* listed date without a value counts once, so a list of events works too.
|
|
53
|
+
*/
|
|
54
|
+
export function readDays(days: unknown): CalendarDays {
|
|
55
|
+
const entries: [unknown, unknown][] = Array.isArray(days)
|
|
56
|
+
? days.map((item): [unknown, unknown] =>
|
|
57
|
+
Array.isArray(item)
|
|
58
|
+
? [item[0], item[1] ?? 1]
|
|
59
|
+
: isObject(item)
|
|
60
|
+
? [item.date, item.value === undefined ? 1 : item.value]
|
|
61
|
+
: [item, 1],
|
|
62
|
+
)
|
|
63
|
+
: isObject(days)
|
|
64
|
+
? Object.entries(days)
|
|
65
|
+
: [];
|
|
66
|
+
const values = new Map<number, number>();
|
|
67
|
+
let dropped = 0;
|
|
68
|
+
for (const [date, value] of entries) {
|
|
69
|
+
const day = parseDay(date);
|
|
70
|
+
const number = finiteNumber(value);
|
|
71
|
+
if (day === null || number === null) {
|
|
72
|
+
dropped += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
values.set(day, (values.get(day) ?? 0) + number);
|
|
76
|
+
}
|
|
77
|
+
return { values, dropped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CalendarLayout {
|
|
81
|
+
/** The Sunday on or before the first day: column 0, row 0. */
|
|
82
|
+
readonly start: number;
|
|
83
|
+
readonly weeks: number;
|
|
84
|
+
/** Month names over the columns where a month begins. */
|
|
85
|
+
readonly months: readonly { readonly week: number; readonly label: string }[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Week columns (Sunday on top) from `first` to `last`, and where to name each month. */
|
|
89
|
+
export function calendarLayout(first: number, last: number): CalendarLayout {
|
|
90
|
+
const start = first - weekday(first);
|
|
91
|
+
const weeks = Math.floor((last - start) / 7) + 1;
|
|
92
|
+
const months: { week: number; label: string }[] = [];
|
|
93
|
+
for (let week = 0; week < weeks; week++) {
|
|
94
|
+
for (let row = 0; row < 7; row++) {
|
|
95
|
+
const day = start + week * 7 + row;
|
|
96
|
+
if (day < first || day > last) continue;
|
|
97
|
+
const date = new Date(day * DAY_MS);
|
|
98
|
+
// The first column, and every column holding a 1st.
|
|
99
|
+
if (day === first || date.getUTCDate() === 1) {
|
|
100
|
+
months.push({ week, label: MONTHS[date.getUTCMonth()]! });
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// A month that starts the range late gets no room before the next one's name.
|
|
106
|
+
if (months.length > 1 && months[1]!.week - months[0]!.week < 3) months.shift();
|
|
107
|
+
return { start, weeks, months };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CalendarStats {
|
|
111
|
+
readonly total: number;
|
|
112
|
+
/** Days with a value above zero. */
|
|
113
|
+
readonly active: number;
|
|
114
|
+
/** The highest value, and the first day it was reached; null without days. */
|
|
115
|
+
readonly peak: { readonly day: number; readonly value: number } | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function calendarStats(values: ReadonlyMap<number, number>): CalendarStats {
|
|
119
|
+
let total = 0;
|
|
120
|
+
let active = 0;
|
|
121
|
+
let peak: { day: number; value: number } | null = null;
|
|
122
|
+
for (const day of [...values.keys()].sort((a, b) => a - b)) {
|
|
123
|
+
const value = values.get(day)!;
|
|
124
|
+
// Float noise from adding 0.1s must not show as 0.30000000000000004.
|
|
125
|
+
total = Number((total + value).toPrecision(12));
|
|
126
|
+
if (value > 0) active += 1;
|
|
127
|
+
if (peak === null || value > peak.value) peak = { day, value };
|
|
128
|
+
}
|
|
129
|
+
return { total, active, peak };
|
|
130
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Pure chart arithmetic: stacking, pie slicing, rounding, scales and the
|
|
2
|
+
// geometry of the radial and grid charts. No DOM, so the numbers a chart
|
|
3
|
+
// draws are testable without rendering it.
|
|
4
|
+
import { formatNumber } from "./values";
|
|
5
|
+
|
|
6
|
+
/** One series' piece of a stacked column, in data units. */
|
|
7
|
+
export interface Segment {
|
|
8
|
+
from: number;
|
|
9
|
+
to: number;
|
|
10
|
+
/** The outermost non-empty piece on its side of zero: the one with rounded corners. */
|
|
11
|
+
outer: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Diverging stack per label: positive values pile up from zero, negative ones
|
|
16
|
+
* pile down from it, in series order. A missing value stays a gap (null) and
|
|
17
|
+
* leaves the stack under the next series unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function stackSeries(series: readonly (readonly (number | null)[])[]): (Segment | null)[][] {
|
|
20
|
+
const count = Math.max(0, ...series.map((values) => values.length));
|
|
21
|
+
const stacked = series.map(() => Array.from<Segment | null>({ length: count }).fill(null));
|
|
22
|
+
for (let index = 0; index < count; index++) {
|
|
23
|
+
let up = 0;
|
|
24
|
+
let down = 0;
|
|
25
|
+
let lastUp: Segment | null = null;
|
|
26
|
+
let lastDown: Segment | null = null;
|
|
27
|
+
for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex++) {
|
|
28
|
+
const value = series[seriesIndex]![index] ?? null;
|
|
29
|
+
if (value === null || !Number.isFinite(value)) continue;
|
|
30
|
+
const segment: Segment =
|
|
31
|
+
value >= 0 ? { from: up, to: (up += value), outer: false } : { from: down, to: (down += value), outer: false };
|
|
32
|
+
stacked[seriesIndex]![index] = segment;
|
|
33
|
+
if (value > 0) lastUp = segment;
|
|
34
|
+
if (value < 0) lastDown = segment;
|
|
35
|
+
}
|
|
36
|
+
if (lastUp !== null) lastUp.outer = true;
|
|
37
|
+
if (lastDown !== null) lastDown.outer = true;
|
|
38
|
+
}
|
|
39
|
+
return stacked;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Slice {
|
|
43
|
+
label: string;
|
|
44
|
+
value: number;
|
|
45
|
+
/** Position in the source data; -1 for the folded "Other" slice. */
|
|
46
|
+
source: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pie slices in data order. Only positive finite values count; past `max`
|
|
51
|
+
* slices the largest `max - 1` stay and the rest fold into a trailing "Other".
|
|
52
|
+
*/
|
|
53
|
+
export function pieSlices(labels: readonly string[], values: readonly (number | null)[], max = 8): Slice[] {
|
|
54
|
+
const slices = values.flatMap((value, source) =>
|
|
55
|
+
value !== null && Number.isFinite(value) && value > 0
|
|
56
|
+
? [{ label: labels[source] || `Item ${source + 1}`, value, source }]
|
|
57
|
+
: [],
|
|
58
|
+
);
|
|
59
|
+
if (slices.length <= max) return slices;
|
|
60
|
+
const kept = new Set(
|
|
61
|
+
[...slices]
|
|
62
|
+
.sort((a, b) => b.value - a.value || a.source - b.source)
|
|
63
|
+
.slice(0, Math.max(0, max - 1))
|
|
64
|
+
.map((slice) => slice.source),
|
|
65
|
+
);
|
|
66
|
+
const other = slices.reduce((sum, slice) => (kept.has(slice.source) ? sum : sum + slice.value), 0);
|
|
67
|
+
return [...slices.filter((slice) => kept.has(slice.source)), { label: "Other", value: other, source: -1 }];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whole percentages of the total that always add up to 100 (largest
|
|
72
|
+
* remainder): the shares that lost the most to rounding down get the spare points.
|
|
73
|
+
*/
|
|
74
|
+
export function percentages(values: readonly number[]): number[] {
|
|
75
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
76
|
+
if (!(total > 0) || !Number.isFinite(total)) return values.map(() => 0);
|
|
77
|
+
const exact = values.map((value) => (value / total) * 100);
|
|
78
|
+
const whole = exact.map(Math.floor);
|
|
79
|
+
const spare = 100 - whole.reduce((sum, value) => sum + value, 0);
|
|
80
|
+
const order = exact
|
|
81
|
+
.map((share, index) => ({ index, remainder: share - whole[index]! }))
|
|
82
|
+
.sort((a, b) => b.remainder - a.remainder || values[b.index]! - values[a.index]! || a.index - b.index);
|
|
83
|
+
for (let k = 0; k < spare; k++) whole[order[k % order.length]!.index]! += 1;
|
|
84
|
+
return whole;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** "Trend from 12 to 18, up 50%": what a sparkline shows, for screen readers. */
|
|
88
|
+
export function trendLabel(values: readonly number[]): string {
|
|
89
|
+
if (values.length < 2) return "No trend";
|
|
90
|
+
const first = values[0]!;
|
|
91
|
+
const last = values[values.length - 1]!;
|
|
92
|
+
const base = `Trend from ${formatNumber(first)} to ${formatNumber(last)}`;
|
|
93
|
+
if (first === last) return `${base}, flat`;
|
|
94
|
+
const direction = last > first ? "up" : "down";
|
|
95
|
+
if (first === 0) return `${base}, ${direction}`;
|
|
96
|
+
return `${base}, ${direction} ${formatNumber(Math.round(Math.abs((last - first) / first) * 100))}%`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 1, 2, 2.5 or 5 × 10^k nearest above `value` (> 0): a step people can read. */
|
|
100
|
+
export function niceStep(value: number): number {
|
|
101
|
+
const magnitude = 10 ** Math.floor(Math.log10(value));
|
|
102
|
+
for (const factor of [1, 2, 2.5, 5]) {
|
|
103
|
+
if (factor * magnitude >= value) return factor * magnitude;
|
|
104
|
+
}
|
|
105
|
+
return 10 * magnitude;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An axis: readable bounds and the ticks between them, both ends included. */
|
|
109
|
+
export interface Scale {
|
|
110
|
+
readonly min: number;
|
|
111
|
+
readonly max: number;
|
|
112
|
+
readonly ticks: readonly number[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A scale covering `low`..`high` in about `count` readable steps. `zero`
|
|
117
|
+
* keeps zero in range (bars, radii); a single value gets one step around it.
|
|
118
|
+
*/
|
|
119
|
+
export function niceScale(low: number, high: number, count = 4, zero = false): Scale {
|
|
120
|
+
const from = zero ? Math.min(low, 0) : low;
|
|
121
|
+
const to = zero ? Math.max(high, 0) : high;
|
|
122
|
+
const step = niceStep((to - from || Math.abs(to) || 1) / count);
|
|
123
|
+
const min = Math.floor(from / step) * step;
|
|
124
|
+
let max = Math.ceil(to / step) * step;
|
|
125
|
+
if (max === min) max = min + step;
|
|
126
|
+
const ticks = Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) =>
|
|
127
|
+
// Rounded, so 0.1-steps read 0.3 rather than 0.30000000000000004.
|
|
128
|
+
Number((min + i * step).toPrecision(12)),
|
|
129
|
+
);
|
|
130
|
+
return { min: ticks[0]!, max: ticks[ticks.length - 1]!, ticks };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A number, or a string that is exactly one (`"2.5"`, `" 10 "`); else null. Scatter x values. */
|
|
134
|
+
export function numericValue(value: unknown): number | null {
|
|
135
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
136
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
137
|
+
const number = Number(value.trim());
|
|
138
|
+
return Number.isFinite(number) ? number : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A ratio as a readable percentage: whole from 10%, one decimal below, `<0.1%` for slivers. */
|
|
142
|
+
export function formatShare(ratio: number): string {
|
|
143
|
+
if (!Number.isFinite(ratio)) return "—";
|
|
144
|
+
const percent = ratio * 100;
|
|
145
|
+
if (percent === 0) return "0%";
|
|
146
|
+
if (Math.abs(percent) < 0.1) return "<0.1%";
|
|
147
|
+
return `${formatNumber(Number(percent.toFixed(Math.abs(percent) < 10 ? 1 : 0)))}%`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ScatterPoint {
|
|
151
|
+
/** Position of its series. */
|
|
152
|
+
readonly series: number;
|
|
153
|
+
/** Position in `labels` (and in its series' values). */
|
|
154
|
+
readonly index: number;
|
|
155
|
+
readonly x: number;
|
|
156
|
+
readonly y: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Every pair with both an x and a y, ordered by x, then series: the order keys step through. */
|
|
160
|
+
export function scatterPoints(
|
|
161
|
+
xs: readonly (number | null)[],
|
|
162
|
+
series: readonly (readonly (number | null)[])[],
|
|
163
|
+
): ScatterPoint[] {
|
|
164
|
+
const points: ScatterPoint[] = [];
|
|
165
|
+
series.forEach((values, seriesIndex) => {
|
|
166
|
+
xs.forEach((x, index) => {
|
|
167
|
+
const y = values[index] ?? null;
|
|
168
|
+
if (x !== null && y !== null && Number.isFinite(x) && Number.isFinite(y)) {
|
|
169
|
+
points.push({ series: seriesIndex, index, x, y });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
return points.sort((a, b) => a.x - b.x || a.series - b.series || a.index - b.index);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The point closest to (`x`, `y`) within `radius`, or null when none is that close. */
|
|
177
|
+
export function nearestPoint(
|
|
178
|
+
points: readonly (readonly [number, number])[],
|
|
179
|
+
x: number,
|
|
180
|
+
y: number,
|
|
181
|
+
radius: number,
|
|
182
|
+
): number | null {
|
|
183
|
+
let best: number | null = null;
|
|
184
|
+
let bestDistance = radius * radius;
|
|
185
|
+
points.forEach(([px, py], index) => {
|
|
186
|
+
const distance = (px - x) ** 2 + (py - y) ** 2;
|
|
187
|
+
if (distance <= bestDistance) {
|
|
188
|
+
best = index;
|
|
189
|
+
bestDistance = distance;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return best;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Where `ratio` (0 centre, 1 rim) lies on spoke `axis` of `count`: the first straight up, then clockwise. */
|
|
196
|
+
export function radarPoint(
|
|
197
|
+
cx: number,
|
|
198
|
+
cy: number,
|
|
199
|
+
radius: number,
|
|
200
|
+
axis: number,
|
|
201
|
+
count: number,
|
|
202
|
+
ratio: number,
|
|
203
|
+
): readonly [number, number] {
|
|
204
|
+
const angle = (axis / count) * Math.PI * 2;
|
|
205
|
+
return [cx + radius * ratio * Math.sin(angle), cy - radius * ratio * Math.cos(angle)];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface FunnelStage {
|
|
209
|
+
readonly label: string;
|
|
210
|
+
readonly value: number;
|
|
211
|
+
/** Bar width as a share of the widest stage, 0 to 1. */
|
|
212
|
+
readonly width: number;
|
|
213
|
+
/** value / first stage's value; null when the first is not positive. */
|
|
214
|
+
readonly ofFirst: number | null;
|
|
215
|
+
/** value / previous stage's value; null for the first stage or after a non-positive one. */
|
|
216
|
+
readonly ofPrevious: number | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Stages with a number, in order; a stage whose value has not arrived yet is left out. */
|
|
220
|
+
export function funnelStages(labels: readonly string[], values: readonly (number | null)[]): FunnelStage[] {
|
|
221
|
+
const present = values.flatMap((value, index) =>
|
|
222
|
+
value !== null && Number.isFinite(value) ? [{ label: labels[index] || `Stage ${index + 1}`, value }] : [],
|
|
223
|
+
);
|
|
224
|
+
const widest = Math.max(0, ...present.map((stage) => stage.value));
|
|
225
|
+
const first = present[0]?.value ?? 0;
|
|
226
|
+
return present.map((stage, index) => {
|
|
227
|
+
const previous = index === 0 ? 0 : present[index - 1]!.value;
|
|
228
|
+
return {
|
|
229
|
+
...stage,
|
|
230
|
+
width: widest > 0 ? Math.max(0, stage.value) / widest : 0,
|
|
231
|
+
ofFirst: first > 0 ? stage.value / first : null,
|
|
232
|
+
ofPrevious: index > 0 && previous > 0 ? stage.value / previous : null,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Lowest and highest finite value, or null when there is none. */
|
|
238
|
+
export function valueRange(values: readonly (number | null)[]): { min: number; max: number } | null {
|
|
239
|
+
const numbers = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
|
240
|
+
return numbers.length === 0 ? null : { min: Math.min(...numbers), max: Math.max(...numbers) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Where `value` sits between `min` and `max`, 0 to 1; a flat range reads as full. */
|
|
244
|
+
export function intensity(value: number, min: number, max: number): number {
|
|
245
|
+
if (!(max > min)) return 1;
|
|
246
|
+
return Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A count's shade, GitHub-style: 0 for nothing (zero or below), else 1..`levels` by its share of `max`. */
|
|
250
|
+
export function heatLevel(value: number, max: number, levels = 4): number {
|
|
251
|
+
if (!(value > 0) || !(max > 0)) return 0;
|
|
252
|
+
return Math.min(levels, Math.max(1, Math.ceil((value / max) * levels)));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A point on the upper half circle: ratio 0 at the left end, 1 at the right, through the top. */
|
|
256
|
+
export function dialPoint(cx: number, cy: number, radius: number, ratio: number): readonly [number, number] {
|
|
257
|
+
const angle = Math.PI * (1 - ratio);
|
|
258
|
+
return [cx + radius * Math.cos(angle), cy - radius * Math.sin(angle)];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The cell an arrow key moves to in a `rows` × `cols` grid (Home/End: the
|
|
263
|
+
* row's ends), clamped at the edges; null for any other key.
|
|
264
|
+
*/
|
|
265
|
+
export function stepGrid(
|
|
266
|
+
key: string,
|
|
267
|
+
row: number,
|
|
268
|
+
col: number,
|
|
269
|
+
rows: number,
|
|
270
|
+
cols: number,
|
|
271
|
+
): readonly [number, number] | null {
|
|
272
|
+
const clamp = (value: number, size: number) => Math.min(size - 1, Math.max(0, value));
|
|
273
|
+
switch (key) {
|
|
274
|
+
case "ArrowLeft":
|
|
275
|
+
return [row, clamp(col - 1, cols)];
|
|
276
|
+
case "ArrowRight":
|
|
277
|
+
return [row, clamp(col + 1, cols)];
|
|
278
|
+
case "ArrowUp":
|
|
279
|
+
return [clamp(row - 1, rows), col];
|
|
280
|
+
case "ArrowDown":
|
|
281
|
+
return [clamp(row + 1, rows), col];
|
|
282
|
+
case "Home":
|
|
283
|
+
return [row, 0];
|
|
284
|
+
case "End":
|
|
285
|
+
return [row, cols - 1];
|
|
286
|
+
default:
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|