Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/webpage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Webpage

on:
push:
branches: [main]
pull_request:
workflow_run:
workflows: ["Run Python Script and Commit Changes"]
types: [completed]
branches: [main]

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install zola
uses: taiki-e/install-action@v2
with:
tool: zola@0.20.0
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.24
- name: Build
run: bun install --frozen-lockfile && bun test && bun run build && zola check
working-directory: webpage
- uses: actions/upload-pages-artifact@v3
with:
path: webpage/public

deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
webpage/public/
webpage/static/data/
webpage/node_modules/
webpage/static/js/params.js
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "webpage/themes/abridge"]
path = webpage/themes/abridge
url = https://github.com/Jieiku/abridge
Binary file added webpage/bun.lockb
Binary file not shown.
29 changes: 29 additions & 0 deletions webpage/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
base_url = "https://patrickelectric.work/ParameterRepository"
title = "ArduPilot Parameters"
description = "Search ArduPilot parameters across vehicles and firmware versions."
default_language = "en"
theme = "abridge"
compile_sass = true
build_search_index = false
generate_feeds = false

[extra]
menu = [
{url = "params", name = "Parameters", slash = true, blank = false, size = "s110"},
{url = "about", name = "About", slash = true, blank = false, size = "s110"},
]
menu_footer = []
textlogo = "ArduPilot Parameters"
author = "ArduPilot"
toc = false
recent = false
series = false
footer_credit = true
seo = false
search_library = false
js_bundle = true
js_switcher = true
js_switcher_default = "dark"
integrity = true
pwa = false
favicon_svg = "favicon.svg"
3 changes: 3 additions & 0 deletions webpage/content/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
+++
redirect_to = "params/"
+++
14 changes: 14 additions & 0 deletions webpage/content/about.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
+++
title = "About"
description = "Where the parameter data comes from."
+++

Parameter data is generated from each ArduPilot firmware branch's `apm.pdef.xml`
and collected in the [ParameterRepository](https://github.com/ArduPilot/ParameterRepository),
which a bot refreshes every five days. This site searches them in the browser:
every word typed must start a word in the parameter name or description, with a
fuzzy fallback when nothing matches.

"latest" is the highest version folder available for a vehicle. Values, bitmasks,
ranges and units come straight from the metadata; defaults are board-specific and
are not part of it.
5 changes: 5 additions & 0 deletions webpage/content/params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
+++
title = "Parameters"
description = "Search ArduPilot parameters by vehicle and firmware version."
template = "params.html"
+++
13 changes: 13 additions & 0 deletions webpage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "webpage",
"private": true,
"scripts": {
"data": "bun scripts/data.js",
"bundle": "bun build src/params.js --outfile static/js/params.js --minify",
"build": "bun run data && bun run bundle && zola build",
"serve": "bun run data && (bun run bundle --watch & zola serve)"
},
"dependencies": {
"minisearch": "7.2.0"
}
}
11 changes: 11 additions & 0 deletions webpage/sass/_extra.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.pctl { display: flex; flex-wrap: wrap; gap: .5em; align-items: center; margin: 1em 0; }
.pctl input { flex: 1; min-width: 12em; }
#chips { display: flex; flex-wrap: wrap; gap: .3em; max-height: 6em; overflow-y: auto; margin-bottom: 1em; }
#chips button { font-size: .8em; padding: .1em .5em; }
#chips button[aria-pressed="true"] { background: var(--a1); color: var(--c1); }
#params { width: 100%; border-collapse: collapse; }
#params[aria-busy] { opacity: .5; }
#params th { position: sticky; top: 0; background: var(--c2); text-align: left; }
#params td.n { font-family: monospace; white-space: nowrap; }
#params tr.p { cursor: pointer; }
#params tr.x td { background: var(--c2); font-size: .9em; white-space: pre-wrap; }
4 changes: 4 additions & 0 deletions webpage/sass/abridge.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@use '../themes/abridge/sass/abridge' with (
$mw: 1200px,
);
@use 'extra';
15 changes: 15 additions & 0 deletions webpage/scripts/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copy every ../<Vehicle>-<version>/apm.pdef.json into static/data and list them in index.json
import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'fs';

const out = 'static/data';
mkdirSync(out, { recursive: true });
const index = {};
for (const dir of readdirSync('..').filter(d => /^\w+-[\d.]+$/.test(d))) {
const [vehicle, version] = dir.split('-');
copyFileSync(`../${dir}/apm.pdef.json`, `${out}/${dir}.json`);
(index[vehicle] ??= []).push(version);
}
for (const versions of Object.values(index)) versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
// Copter first so it is the default vehicle
const vehicles = Object.entries(index).sort(([a], [b]) => (a !== 'Copter') - (b !== 'Copter') || a.localeCompare(b));
writeFileSync(`${out}/index.json`, JSON.stringify(Object.fromEntries(vehicles)));
11 changes: 11 additions & 0 deletions webpage/src/flatten.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// apm.pdef.json groups parameters by prefix; the page wants one sorted list without duplicates
export default function flatten(pdef) {
const byName = new Map();
for (const [group, params] of Object.entries(pdef)) {
if (group === 'json') continue; // format version marker
for (const [n, p] of Object.entries(params)) {
byName.set(n, { n, d: p.Description || p.DisplayName || '', r: p.Range && [p.Range.low, p.Range.high], u: p.Units, v: p.Values, b: p.Bitmask });
}
}
return [...byName.values()].sort((a, b) => (a.n > b.n) - (a.n < b.n));
}
11 changes: 11 additions & 0 deletions webpage/src/flatten.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from 'bun:test';
import flatten from './flatten';

test('flatten skips the marker, dedupes by name and sorts', () => {
const pdef = {
json: { version: 0 },
WP_: { WP_SPEED: { Description: 'a', Range: { low: '0', high: '1' } } },
Rover: { WP_SPEED: { Description: 'b' }, ACRO_X: { DisplayName: 'c', Units: 'm' } },
};
expect(flatten(pdef)).toEqual([{ n: 'ACRO_X', d: 'c', u: 'm' }, { n: 'WP_SPEED', d: 'b' }]);
});
119 changes: 119 additions & 0 deletions webpage/src/params.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import MiniSearch from 'minisearch';
import flatten from './flatten';

const idx = JSON.parse(document.getElementById('idx').textContent);
const $ = id => document.getElementById(id);
const table = $('params'), tbody = table.querySelector('tbody');
const cache = new Map();
let rows = [], ms, hits = [], shown = 0, group = '', gen = 0;

const fmtMap = m => Object.entries(m).map(([k, v]) => `${k}: ${v}`).join(', ');
const esc = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// text with every matched term wrapped in <mark>
function marked(text, terms) {
const frag = document.createDocumentFragment();
let last = 0;
for (const m of terms.length ? text.matchAll(new RegExp(terms.map(esc).join('|'), 'gi')) : []) {
frag.append(text.slice(last, m.index), Object.assign(document.createElement('mark'), { textContent: m[0] }));
last = m.index + m[0].length;
}
frag.append(text.slice(last));
return frag;
}

function td(text, cls, terms = []) {
const e = document.createElement('td');
e.append(marked(text, terms));
if (cls) e.className = cls;
return e;
}

function load(file) {
if (!cache.has(file)) cache.set(file, fetch(`${table.dataset.base}/${file}.json`).then(r => {
if (!r.ok) throw new Error(`${file}: ${r.status}`);
return r.json();
}).then(flatten).catch(e => { cache.delete(file); throw e; })); // let the next selection retry
return cache.get(file);
}

function fillVersions() {
$('version').innerHTML = idx[$('vehicle').value].map((v, i) => `<option value="${v}">${v}${i ? '' : ' (latest)'}</option>`).join('');
}

function fillChips() {
const groups = new Set(rows.map(r => r.n.split('_')[0]));
$('chips').innerHTML = [...groups].sort().map(g => `<button aria-pressed="${g === group}" data-g="${g}">${g}</button>`).join('');
}

function search() {
if (!ms) return; // typed before the first load, reload() searches when done
const q = $('q').value.trim();
// every term must match; fuzzy only as a fallback since results are alphabetical, not ranked
hits = q ? ms.search(q, { prefix: true, combineWith: 'AND' }) : rows.slice();
if (q && !hits.length) hits = ms.search(q, { prefix: true, combineWith: 'AND', fuzzy: 0.2 });
hits.sort((a, b) => (a.n > b.n) - (a.n < b.n)); // codepoint order, same as the JSON
const i = hits.findIndex(h => h.n === q.toUpperCase().replace(/\s+/g, '_'));
if (i > 0) hits.unshift(...hits.splice(i, 1));
if (group) hits = hits.filter(h => h.n.split('_')[0] === group);
$('count').textContent = `${hits.length} parameters`;
tbody.textContent = ''; shown = 0; more();
history.replaceState(null, '', '#' + new URLSearchParams({ v: $('vehicle').value, r: $('version').value, q, g: group }));
}

function more() {
const frag = document.createDocumentFragment();
for (const h of hits.slice(shown, shown + 300)) {
const tr = document.createElement('tr');
tr.className = 'p';
const terms = h.match ? Object.keys(h.match) : [];
tr.append(td(h.n, 'n', terms), td(h.d, null, terms), td(h.r ? h.r.join(' to ') : ''), td(h.u || ''));
tr.onclick = () => {
const next = tr.nextElementSibling;
if (next?.classList.contains('x')) return next.remove();
const x = document.createElement('tr'); x.className = 'x';
const cell = td([h.v && 'Values: ' + fmtMap(h.v), h.b && 'Bitmask: ' + fmtMap(h.b)].filter(Boolean).join('\n') || 'No values or bitmask');
cell.colSpan = 4; x.append(cell); tr.after(x);
};
frag.append(tr);
}
shown += 300; tbody.append(frag); $('more').hidden = shown >= hits.length;
}

async function reload() {
const file = `${$('vehicle').value}-${$('version').value}`, my = ++gen;
$('count').textContent = 'Loading…';
table.setAttribute('aria-busy', 'true');
try {
rows = await load(file);
} catch (e) {
if (my === gen) $('count').textContent = `Failed to load ${file}`;
throw e;
} finally {
if (my === gen) table.removeAttribute('aria-busy');
}
if (my !== gen) return; // a newer selection won
ms = new MiniSearch({ fields: ['n', 'd'], storeFields: ['n', 'd', 'r', 'u', 'v', 'b'], idField: 'n' });
ms.addAll(rows);
fillChips(); search();
}

$('vehicle').onchange = () => { fillVersions(); reload(); };
$('version').onchange = reload;
$('q').oninput = search;
$('more').onclick = more;
$('chips').onclick = e => {
const g = e.target.dataset.g;
if (g === undefined) return;
group = group === g ? '' : g;
fillChips(); search();
};

const h = new URLSearchParams(location.hash.slice(1));
if (Object.hasOwn(idx, h.get('v'))) $('vehicle').value = h.get('v');
fillVersions();
$('version').value = h.get('r');
if (!$('version').value) $('version').selectedIndex = 0;
$('q').value = h.get('q') || '';
group = h.get('g') || '';
reload();
25 changes: 25 additions & 0 deletions webpage/templates/params.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{%- block seo %}
{{- super() }}
{{- macros_seo::seo(config=config, title=page.title, title_addition=title_separator ~ config.title, description=page.description, is_page=true) }}
{%- endblock seo %}
{%- block content %}
{%- set idx = load_data(path="static/data/index.json") %}
<article>
<h1>{{ page.title }}</h1>
<div class="pctl">
<select id="vehicle" aria-label="Vehicle">{% for v, vers in idx %}<option>{{ v }}</option>{% endfor %}</select>
<select id="version" aria-label="Version"></select>
<input id="q" type="search" placeholder="Search name or description" autocomplete="off" aria-label="Search">
<span id="count" aria-live="polite"></span>
</div>
<div id="chips"></div>
<table id="params" data-base="{{ get_url(path='data') }}">
<thead><tr><th>Name</th><th>Description</th><th>Range</th><th>Units</th></tr></thead>
<tbody></tbody>
</table>
<button id="more" hidden>Load more</button>
<script id="idx" type="application/json">{{ idx | json_encode() | safe }}</script>
<script type="module" src="{{ get_url(path='js/params.js') }}"></script>
</article>
{%- endblock content %}
1 change: 1 addition & 0 deletions webpage/themes/abridge
Submodule abridge added at d52609
Loading