initial commit
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# web-ext sign / run
|
||||
web-ext-artifacts/
|
||||
.web-extension-id
|
||||
|
||||
# AMO API credentials — never commit these
|
||||
.env
|
||||
*.secret
|
||||
|
||||
# Node, if web-ext ends up installed locally
|
||||
node_modules/
|
||||
|
||||
# Editors / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
@@ -0,0 +1,182 @@
|
||||
# Piscine Graph Tweaks
|
||||
|
||||
Firefox extension for the EPITA piscine exercise graph (`exercises_c`,
|
||||
`exercises_shell`, …) on `intra.forge.epita.fr`. Two things:
|
||||
|
||||
**A progress panel**, bottom right of the page:
|
||||
|
||||
- **validated / total** with the percentage and a segmented bar;
|
||||
- a separate **required** count, which is the one that actually matters;
|
||||
- breakdown: validated, required left, bonus left, locked.
|
||||
|
||||
**A full screen view of the graph**, because the page crams a ~5000px-wide
|
||||
diagram into a 200px-tall box:
|
||||
|
||||
- opens at a readable zoom instead of shrinking everything to fit;
|
||||
- **search** — dims everything but the matches, and recentres when there is only one;
|
||||
- **minimap** bottom right, coloured by status; click or drag it to jump anywhere;
|
||||
- **arrow keys** to pan (Shift for 3×), wheel to zoom, shift+wheel to pan sideways,
|
||||
drag to pan, double-click to zoom in, click a node to open the exercise;
|
||||
- `/` search · `Home` readable view · `0` fit everything · `+` / `−` zoom · `Esc` close.
|
||||
|
||||
No network request is made: everything is read from the page itself.
|
||||
|
||||
## How it reads the graph
|
||||
|
||||
The graph is a mermaid `stateDiagram` rendered as inline SVG, and each node keeps
|
||||
its whole state in its `id`:
|
||||
|
||||
```
|
||||
state-"_required=true/_validated=true/_accessible=true/…/exercises_c/clang~format"-0
|
||||
```
|
||||
|
||||
So the three booleans are parsed straight from the id — no color guessing — and
|
||||
`~` is mermaid's escape for `-` in the path. Status mapping:
|
||||
|
||||
| `_validated` | `_accessible` | `_required` | status |
|
||||
|--------------|---------------|-------------|---------------|
|
||||
| `true` | – | – | validated |
|
||||
| `false` | `false` | – | locked |
|
||||
| `false` | `true` | `true` | required left |
|
||||
| `false` | `true` | `false` | bonus left |
|
||||
|
||||
Verified against the live page: 91 nodes parsed, the only skipped element being
|
||||
mermaid's `state-root_start-1` marker.
|
||||
|
||||
## Why there is no "started but unfinished" state
|
||||
|
||||
There is none in the page. Each node carries exactly those three booleans, and
|
||||
`_validated` only flips once everything passes. An exercise started this morning
|
||||
and one never opened produce **byte-for-byte identical markup** — compare
|
||||
`gdb_wristwatch` and `test_a_bit`, both `_required=true/_validated=false/
|
||||
_accessible=true`. So per-exercise completion cannot come from this page, and the
|
||||
extension does not pretend otherwise.
|
||||
|
||||
If you ever want real "started" detection, it has to come from the GitLab API
|
||||
rather than the graph — one call listing the exercise projects and their
|
||||
`last_activity_at` would say which ones you have pushed to:
|
||||
|
||||
```
|
||||
/api/v4/groups/<url-encoded group path>/projects?per_page=100&order_by=last_activity_at
|
||||
```
|
||||
|
||||
That is one request for the whole graph, not one per exercise. Not wired in: it
|
||||
needs checking against the real API response first.
|
||||
|
||||
## How the full screen view works
|
||||
|
||||
It **moves** the real `<svg>` into the overlay rather than cloning it (a
|
||||
placeholder holds its spot in the page), so mermaid's own `<style>` block — which
|
||||
lives inside the SVG and is scoped to its id — keeps working untouched. It then
|
||||
takes over the `svg-pan-zoom` viewport `<g>` transform instead of fighting that
|
||||
instance. Inherited CSS custom properties (`--required-validated` and friends) do
|
||||
not survive the move, so they are resolved and re-declared on the overlay first.
|
||||
Closing puts every attribute back exactly as it was.
|
||||
|
||||
---
|
||||
|
||||
## Installing (for you, while developing)
|
||||
|
||||
1. Open `about:debugging#/runtime/this-firefox`
|
||||
2. **Load Temporary Add-on…**
|
||||
3. Pick this folder's `manifest.json`
|
||||
4. Open the graph page — the panel shows up bottom right
|
||||
|
||||
"Temporary" means it **disappears when Firefox restarts**. That is the
|
||||
development mode: after each code change, hit **Reload** on `about:debugging`.
|
||||
For a permanent install, use the same route as your friends below.
|
||||
|
||||
## Sharing it with friends (without publishing to the store)
|
||||
|
||||
Build the package:
|
||||
|
||||
```sh
|
||||
./build.sh # -> dist/piscine-graph-tweaks-<version>.zip and .xpi
|
||||
```
|
||||
|
||||
Then pick one of three routes. The catch: **Firefox release refuses to install an
|
||||
unsigned extension permanently**, so a `.xpi` dropped in a Discord channel will
|
||||
not install by double-click.
|
||||
|
||||
### Option A — the zip plus temporary loading (simplest, zero setup)
|
||||
|
||||
Send the `.zip`, they unpack it, then `about:debugging` → *Load Temporary
|
||||
Add-on…* → `manifest.json`.
|
||||
|
||||
- works on any Firefox, right away
|
||||
- has to be redone every time the browser restarts
|
||||
|
||||
### Option B — "unlisted" signing on AMO (best for daily use)
|
||||
|
||||
Mozilla signs the extension **without publishing it** in the catalogue: it stays
|
||||
unlisted, you distribute the `.xpi` yourself, and it installs permanently on a
|
||||
normal Firefox.
|
||||
|
||||
1. Create an account on https://addons.mozilla.org, then generate an API key at
|
||||
https://addons.mozilla.org/developers/addon/api/key/
|
||||
2. Sign it:
|
||||
|
||||
```sh
|
||||
npm install --global web-ext
|
||||
web-ext sign --channel=unlisted \
|
||||
--api-key="$AMO_JWT_ISSUER" --api-secret="$AMO_JWT_SECRET"
|
||||
```
|
||||
|
||||
3. You get a **signed** `.xpi` in `web-ext-artifacts/`. Friends open it with
|
||||
Firefox (drag into the window, or `Ctrl+O`) → *Add*.
|
||||
|
||||
The extension id is already pinned in `manifest.json`
|
||||
(`piscine-graph-tweaks@valentin`) — required for signing, and it must not change
|
||||
after the first signature.
|
||||
|
||||
### Option C — Firefox Developer Edition / Nightly / ESR
|
||||
|
||||
On those builds only, signature enforcement can be turned off: `about:config` →
|
||||
`xpinstall.signatures.required` → `false`, then install the unsigned `.xpi`.
|
||||
Pointless on Firefox release, where the pref is ignored.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
manifest.json extension manifest (MV3) and the matched URLs
|
||||
src/content.js node parsing, stats, the progress panel
|
||||
src/viewer.js full screen view: pan/zoom, search, minimap
|
||||
src/panel.css every injected style
|
||||
src/inject.js injects the network hook into the page world
|
||||
src/net-hook.js records what the page fetches (discovery aid)
|
||||
src/icon.svg icon
|
||||
tools/dump-graph.js console snippet: parsed nodes, works without the extension
|
||||
tools/make-fixture.js console snippet: captures the graph for offline testing
|
||||
tests/build-harness.js rebuilds tests/harness.html from a fixture + current src
|
||||
tests/shot.sh screenshots the harness with headless Firefox
|
||||
build.sh produces dist/*.zip and dist/*.xpi
|
||||
```
|
||||
|
||||
`src/viewer.js` must load **before** `src/content.js` — the panel's button calls
|
||||
`window.__pgtViewer`.
|
||||
|
||||
## Testing
|
||||
|
||||
Two ways, both offline-friendly:
|
||||
|
||||
- **Against a captured page.** Run `tools/make-fixture.js` in the console on the
|
||||
graph page, save it with `wl-paste > tests/fixture.json`, then
|
||||
`node tests/build-harness.js && tests/shot.sh`. The harness inlines the current
|
||||
`src/` so any edit is one command away from a screenshot.
|
||||
- **Against the live page.** Anything driving a real browser works; the parser and
|
||||
both screenshots in this repo's history were checked that way.
|
||||
|
||||
## Other projects
|
||||
|
||||
The manifest already matches `*/root/exercises_*` on `intra.forge.epita.fr`, so
|
||||
`exercises_shell` and any future `exercises_python` work with no change. The panel
|
||||
title is derived from the project segment of the node ids.
|
||||
|
||||
## If the numbers look wrong
|
||||
|
||||
The graph generator would have to have changed. Open the console on the page and
|
||||
run `__pgtDump()`: it prints the parsed nodes and the raw HTML of the first one,
|
||||
which is what `RE_STATE_ID` in `src/content.js` needs to match. `__pgtNet()` lists
|
||||
what the page fetched, in case richer data ever appears.
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# Builds the package to hand out: dist/piscine-graph-tweaks-<version>.zip
|
||||
# A Firefox .xpi is just a zip, so the .xpi copy is produced alongside it.
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -n 1)
|
||||
if [ -z "$version" ]; then
|
||||
echo "build.sh: no version found in manifest.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
name="piscine-graph-tweaks-$version"
|
||||
|
||||
mkdir -p dist
|
||||
rm -f "dist/$name.zip" "dist/$name.xpi"
|
||||
|
||||
# Zip the CONTENTS (manifest.json at the archive root), not the folder itself.
|
||||
# -r: recursive, -X: no system metadata.
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -qr -X "dist/$name.zip" manifest.json src README.md
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
# Dependency-free fallback: same result through the zipfile module.
|
||||
python3 - "dist/$name.zip" manifest.json src README.md <<-'EOF'
|
||||
import os, sys, zipfile
|
||||
|
||||
out, sources = sys.argv[1], sys.argv[2:]
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for src in sources:
|
||||
if os.path.isdir(src):
|
||||
for root, _, files in os.walk(src):
|
||||
for f in sorted(files):
|
||||
p = os.path.join(root, f)
|
||||
z.write(p, os.path.relpath(p, "."))
|
||||
else:
|
||||
z.write(src, src)
|
||||
EOF
|
||||
else
|
||||
echo "build.sh: needs 'zip' (pacman -S zip) or python3" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "dist/$name.zip" "dist/$name.xpi"
|
||||
|
||||
echo "→ dist/$name.zip"
|
||||
echo "→ dist/$name.xpi (unsigned — see README, \"Sharing it with friends\")"
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Piscine Graph Tweaks",
|
||||
"version": "1.0.0",
|
||||
"description": "Progress counter (validated / total, %) on the EPITA piscine graph, plus a full screen view of the graph with search, minimap and keyboard navigation.",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "piscine-graph-tweaks@valentin",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"48": "src/icon.svg",
|
||||
"96": "src/icon.svg"
|
||||
},
|
||||
"host_permissions": [
|
||||
"https://intra.forge.epita.fr/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://intra.forge.epita.fr/*/root/exercises_*"
|
||||
],
|
||||
"js": [
|
||||
"src/inject.js"
|
||||
],
|
||||
"run_at": "document_start",
|
||||
"all_frames": false
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"https://intra.forge.epita.fr/*/root/exercises_*"
|
||||
],
|
||||
"js": [
|
||||
"src/viewer.js",
|
||||
"src/content.js"
|
||||
],
|
||||
"css": [
|
||||
"src/panel.css"
|
||||
],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"src/net-hook.js"
|
||||
],
|
||||
"matches": [
|
||||
"https://intra.forge.epita.fr/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/* Piscine Graph Tweaks — content script
|
||||
*
|
||||
* The graph is a mermaid stateDiagram rendered inline as SVG. Every node carries
|
||||
* its whole state in its id, which is the only thing we need:
|
||||
*
|
||||
* <g class="node statediagram-state"
|
||||
* id='state-"_required=true/_validated=true/_accessible=true/epita~ing~assistants~acu/
|
||||
* piscine~grand~bain~2029~ing1/root/exercises_c/clang~format"-0'>
|
||||
* <a href="/…/exercises_c/clang-format">
|
||||
* <rect style="fill: var(--required-validated)">
|
||||
* …<span class="nodeLabel">clang-format Tutorial</span>
|
||||
*
|
||||
* So the status is read from the id (no color guessing), and `~` in the id path is
|
||||
* mermaid's escape for `-`.
|
||||
*
|
||||
* Note on "in progress": the page has no such state. An exercise that is started
|
||||
* but unfinished is byte-for-byte identical to one never opened — only
|
||||
* _validated flips, and only once everything passes. What we CAN single out is
|
||||
* the actionable queue: required, unlocked, not yet validated.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (window.__pgtLoaded) return;
|
||||
window.__pgtLoaded = true;
|
||||
|
||||
// state-"_required=…/_validated=…/_accessible=…/<path>"-<mermaid counter>
|
||||
const RE_STATE_ID = /_required=(true|false)\/_validated=(true|false)\/_accessible=(true|false)\/([^"]+)"-\d+$/;
|
||||
const RESCAN_DEBOUNCE_MS = 500;
|
||||
|
||||
const STATUS = {
|
||||
done: 'done', // validated, whatever the requirement
|
||||
required: 'required',// required, unlocked, not validated -> the actual TODO
|
||||
bonus: 'bonus', // optional, unlocked, not validated
|
||||
locked: 'locked', // not accessible yet
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ parsing
|
||||
|
||||
function parseNode(g) {
|
||||
const id = g.getAttribute('id') || '';
|
||||
const m = id.match(RE_STATE_ID);
|
||||
if (!m) return null; // start/end markers and anything unexpected
|
||||
|
||||
const [, required, validated, accessible, path] = m;
|
||||
const isRequired = required === 'true';
|
||||
const isValidated = validated === 'true';
|
||||
const isAccessible = accessible === 'true';
|
||||
|
||||
const segments = path.split('/');
|
||||
const slug = (segments[segments.length - 1] || '').replace(/~/g, '-');
|
||||
const project = segments[segments.length - 2] || '';
|
||||
const link = g.querySelector('a');
|
||||
const labelEl = g.querySelector('.nodeLabel');
|
||||
|
||||
return {
|
||||
el: g,
|
||||
shape: g.querySelector('rect.label-container') || g.querySelector('rect'),
|
||||
slug,
|
||||
project,
|
||||
label: (labelEl ? labelEl.textContent : slug).trim(),
|
||||
href: link ? link.getAttribute('href') || '' : '',
|
||||
required: isRequired,
|
||||
validated: isValidated,
|
||||
accessible: isAccessible,
|
||||
status: isValidated ? STATUS.done
|
||||
: !isAccessible ? STATUS.locked
|
||||
: isRequired ? STATUS.required
|
||||
: STATUS.bonus,
|
||||
};
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const seen = new Set();
|
||||
const nodes = [];
|
||||
document.querySelectorAll('svg g.node').forEach((g) => {
|
||||
const node = parseNode(g);
|
||||
if (!node || seen.has(node.slug)) return;
|
||||
seen.add(node.slug);
|
||||
nodes.push(node);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- marking
|
||||
|
||||
/* Marking happens on the SVG itself rather than with overlay badges: the graph
|
||||
lives inside svg-pan-zoom, so anything positioned in page coordinates would
|
||||
drift on every pan and zoom. */
|
||||
function mark(nodes) {
|
||||
for (const n of nodes) {
|
||||
n.el.classList.toggle('pgt-next', n.status === STATUS.required);
|
||||
n.el.classList.toggle('pgt-bonus', n.status === STATUS.bonus);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- panel
|
||||
|
||||
let panel = null;
|
||||
function ensurePanel() {
|
||||
if (panel && panel.isConnected) return panel;
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'pgt-panel';
|
||||
if (localStorage.getItem('pgt.collapsed') === '1') panel.classList.add('pgt-collapsed');
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function pct(a, b) {
|
||||
return b ? Math.round((a / b) * 1000) / 10 : 0;
|
||||
}
|
||||
|
||||
function projectName(nodes) {
|
||||
const raw = (nodes[0] && nodes[0].project) || '';
|
||||
const m = raw.match(/^exercises_(.+)$/);
|
||||
if (!m) return 'Piscine';
|
||||
return 'Piscine ' + (m[1] === 'c' ? 'C' : m[1]);
|
||||
}
|
||||
|
||||
function render(nodes) {
|
||||
const p = ensurePanel();
|
||||
const total = nodes.length;
|
||||
const by = (s) => nodes.filter((n) => n.status === s);
|
||||
const done = by(STATUS.done);
|
||||
const todo = by(STATUS.required);
|
||||
const bonus = by(STATUS.bonus);
|
||||
const locked = by(STATUS.locked);
|
||||
|
||||
const required = nodes.filter((n) => n.required);
|
||||
const requiredDone = required.filter((n) => n.validated);
|
||||
|
||||
p.innerHTML = `
|
||||
<div class="pgt-head">
|
||||
<span class="pgt-title">${escapeHtml(projectName(nodes))}</span>
|
||||
<button class="pgt-btn pgt-toggle" type="button" title="Collapse / expand">–</button>
|
||||
</div>
|
||||
<div class="pgt-body">
|
||||
<div class="pgt-score">
|
||||
<span class="pgt-frac"><b>${done.length}</b><span>/${total}</span></span>
|
||||
<span class="pgt-pct">${pct(done.length, total)}%</span>
|
||||
</div>
|
||||
<div class="pgt-bar" title="Validated / required left / bonus left / locked">
|
||||
<div class="pgt-seg pgt-seg-done" style="width:${pct(done.length, total)}%"></div>
|
||||
<div class="pgt-seg pgt-seg-todo" style="width:${pct(todo.length, total)}%"></div>
|
||||
<div class="pgt-seg pgt-seg-bonus" style="width:${pct(bonus.length, total)}%"></div>
|
||||
<div class="pgt-seg pgt-seg-locked" style="width:${pct(locked.length, total)}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="pgt-sub">
|
||||
Required <b>${requiredDone.length}/${required.length}</b>
|
||||
<span class="pgt-subpct">${pct(requiredDone.length, required.length)}%</span>
|
||||
</div>
|
||||
|
||||
<ul class="pgt-stats">
|
||||
<li><i class="pgt-dot pgt-dot-done"></i>Validated<b>${done.length}</b></li>
|
||||
<li><i class="pgt-dot pgt-dot-todo"></i>Required left<b>${todo.length}</b></li>
|
||||
<li><i class="pgt-dot pgt-dot-bonus"></i>Bonus left<b>${bonus.length}</b></li>
|
||||
${locked.length ? `<li><i class="pgt-dot pgt-dot-locked"></i>Locked<b>${locked.length}</b></li>` : ''}
|
||||
</ul>
|
||||
|
||||
<button class="pgt-btn pgt-big" type="button">Open big view</button>
|
||||
</div>`;
|
||||
|
||||
p.querySelector('.pgt-toggle').addEventListener('click', () => {
|
||||
p.classList.toggle('pgt-collapsed');
|
||||
localStorage.setItem('pgt.collapsed', p.classList.contains('pgt-collapsed') ? '1' : '0');
|
||||
});
|
||||
p.querySelector('.pgt-big').addEventListener('click', () => {
|
||||
if (window.__pgtViewer) window.__pgtViewer.toggle(nodes);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- scan
|
||||
|
||||
let lastSignature = '';
|
||||
function scan(force = false) {
|
||||
const nodes = collect();
|
||||
if (!nodes.length) return;
|
||||
const signature = nodes.map((n) => n.slug + n.status).join('|');
|
||||
if (!force && signature === lastSignature) return;
|
||||
lastSignature = signature;
|
||||
render(nodes);
|
||||
mark(nodes);
|
||||
window.__pgtNodes = nodes; // handy from the console
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
new MutationObserver((muts) => {
|
||||
// Ignore the mutations we cause ourselves, otherwise we loop forever.
|
||||
if (muts.every((m) => m.target.closest && m.target.closest('.pgt-panel'))) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => scan(), RESCAN_DEBOUNCE_MS);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
scan();
|
||||
|
||||
// ------------------------------------------------------------ console help
|
||||
|
||||
/** Raw dump, for tuning the parser if the graph generator ever changes. */
|
||||
window.__pgtDump = () => {
|
||||
const nodes = collect();
|
||||
const report = {
|
||||
url: location.href,
|
||||
total: nodes.length,
|
||||
counts: Object.values(STATUS).reduce((acc, s) => {
|
||||
acc[s] = nodes.filter((n) => n.status === s).length;
|
||||
return acc;
|
||||
}, {}),
|
||||
nodes: nodes.map(({ el, shape, ...rest }) => rest),
|
||||
sampleHtml: nodes.length ? nodes[0].el.outerHTML.slice(0, 800) : '',
|
||||
};
|
||||
console.log(report);
|
||||
return report;
|
||||
};
|
||||
|
||||
/** What the page fetched (see net-hook.js) — is there richer data anywhere? */
|
||||
window.__pgtNet = () => {
|
||||
const log = window.__pgtNetLog || [];
|
||||
console.table(log.map(({ kind, url, status, contentType, length }) =>
|
||||
({ kind, url, status, contentType, length })));
|
||||
return log;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
|
||||
<rect width="96" height="96" rx="20" fill="#1f2128"/>
|
||||
<circle cx="30" cy="28" r="9" fill="#22c55e"/>
|
||||
<circle cx="66" cy="28" r="9" fill="#22c55e"/>
|
||||
<circle cx="30" cy="68" r="9" fill="#f59e0b"/>
|
||||
<circle cx="66" cy="68" r="9" fill="#6b7280"/>
|
||||
<g stroke="#4b5163" stroke-width="4" stroke-linecap="round">
|
||||
<line x1="30" y1="37" x2="30" y2="59"/>
|
||||
<line x1="39" y1="28" x2="57" y2="28"/>
|
||||
<line x1="66" y1="37" x2="66" y2="59"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,27 @@
|
||||
/* Piscine Graph Tweaks — bridge between the page world and the content script.
|
||||
*
|
||||
* Content scripts run in an isolated world, so they cannot patch the page's own
|
||||
* window.fetch. We inject net-hook.js into the page and collect what it reports
|
||||
* here; content.js reads window.__pgtNetLog from the same sandbox.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const api = globalThis.browser || globalThis.chrome;
|
||||
const log = (window.__pgtNetLog = window.__pgtNetLog || []);
|
||||
const MAX_ENTRIES = 120;
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== window) return;
|
||||
const data = e.data;
|
||||
if (!data || data.__pgt !== 'net' || !data.entry) return;
|
||||
log.push(data.entry);
|
||||
if (log.length > MAX_ENTRIES) log.shift();
|
||||
});
|
||||
|
||||
const s = document.createElement('script');
|
||||
s.src = api.runtime.getURL('src/net-hook.js');
|
||||
s.async = false; // run before the page starts fetching
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
s.addEventListener('load', () => s.remove());
|
||||
})();
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/* Piscine Graph Tweaks — network hook (runs in the PAGE world).
|
||||
*
|
||||
* The graph DOM most likely encodes only "green vs not green": an exercise that
|
||||
* is started but unfinished looks exactly like one never opened. The per-exercise
|
||||
* completion, if it exists at all, lives in whatever data the page fetches to
|
||||
* build the graph. This hook wraps fetch() and XMLHttpRequest so we can see those
|
||||
* responses and find out where that data is.
|
||||
*
|
||||
* Everything stays local to the browser: captured bodies are handed to the
|
||||
* extension's content script and never leave the machine.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (window.__pgtNetHook) return;
|
||||
window.__pgtNetHook = true;
|
||||
|
||||
const MAX_BODY = 8000; // truncate captured payloads
|
||||
const KEEP_TYPES = /json|javascript|svg|text\/plain|graphql/i;
|
||||
|
||||
function send(entry) {
|
||||
try {
|
||||
window.postMessage({ __pgt: 'net', entry }, window.location.origin);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function capture(kind, url, method, status, contentType, body) {
|
||||
if (!KEEP_TYPES.test(contentType || '')) return;
|
||||
send({
|
||||
kind,
|
||||
url: String(url),
|
||||
method: method || 'GET',
|
||||
status,
|
||||
contentType,
|
||||
length: body ? body.length : 0,
|
||||
body: body ? body.slice(0, MAX_BODY) : '',
|
||||
truncated: !!body && body.length > MAX_BODY,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- fetch ---------------------------------------------------------------
|
||||
const origFetch = window.fetch;
|
||||
if (typeof origFetch === 'function') {
|
||||
window.fetch = function (...args) {
|
||||
const input = args[0];
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const method = (args[1] && args[1].method) || (input && input.method) || 'GET';
|
||||
return origFetch.apply(this, args).then((res) => {
|
||||
try {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (KEEP_TYPES.test(ct)) {
|
||||
// clone() so the page still gets to read its own body
|
||||
res.clone().text().then(
|
||||
(body) => capture('fetch', url, method, res.status, ct, body),
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
return res;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---- XMLHttpRequest ------------------------------------------------------
|
||||
const XHR = window.XMLHttpRequest;
|
||||
if (XHR && XHR.prototype) {
|
||||
const origOpen = XHR.prototype.open;
|
||||
const origSend = XHR.prototype.send;
|
||||
|
||||
XHR.prototype.open = function (method, url, ...rest) {
|
||||
this.__pgtMethod = method;
|
||||
this.__pgtUrl = url;
|
||||
return origOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
|
||||
XHR.prototype.send = function (...args) {
|
||||
this.addEventListener('load', () => {
|
||||
try {
|
||||
const ct = this.getResponseHeader('content-type') || '';
|
||||
const body = this.responseType === '' || this.responseType === 'text'
|
||||
? this.responseText
|
||||
: (this.responseType === 'json' ? JSON.stringify(this.response) : '');
|
||||
capture('xhr', this.__pgtUrl, this.__pgtMethod, this.status, ct, body);
|
||||
} catch (_) { /* ignore */ }
|
||||
});
|
||||
return origSend.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- inline data already in the page -------------------------------------
|
||||
// Some pages ship the graph data in a <script type="application/json"> tag
|
||||
// rather than fetching it; surface those the same way.
|
||||
function scanInlineJson() {
|
||||
document.querySelectorAll('script[type*="json"]').forEach((s) => {
|
||||
if (s.dataset.pgtSeen) return;
|
||||
s.dataset.pgtSeen = '1';
|
||||
capture('inline', s.id || s.className || '<script json>', 'INLINE', 200,
|
||||
'application/json', s.textContent || '');
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', scanInlineJson, { once: true });
|
||||
} else {
|
||||
scanInlineJson();
|
||||
}
|
||||
})();
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
/* Piscine Graph Tracker — injected UI.
|
||||
Everything is prefixed .pgt- so it cannot collide with GitLab's own styles. */
|
||||
|
||||
.pgt-panel {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2147483000;
|
||||
width: 268px;
|
||||
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #e8eaed;
|
||||
background: #1f2128;
|
||||
border: 1px solid #3a3d47;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, .35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pgt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #262932;
|
||||
border-bottom: 1px solid #3a3d47;
|
||||
}
|
||||
|
||||
.pgt-title { font-weight: 600; }
|
||||
|
||||
.pgt-btn {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
color: #c9ccd4;
|
||||
background: #31353f;
|
||||
border: 1px solid #454a56;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pgt-btn:hover { background: #3b404c; color: #fff; }
|
||||
|
||||
.pgt-collapsed .pgt-body { display: none; }
|
||||
|
||||
.pgt-body { padding: 10px; }
|
||||
|
||||
.pgt-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pgt-frac b { font-size: 24px; font-weight: 700; color: #4ade80; }
|
||||
.pgt-frac span { font-size: 15px; color: #9aa0ac; }
|
||||
.pgt-pct { font-size: 16px; font-weight: 600; color: #cbd5e1; }
|
||||
|
||||
.pgt-bar {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
background: #31353f;
|
||||
}
|
||||
|
||||
.pgt-seg { height: 100%; }
|
||||
.pgt-seg-done { background: #22c55e; }
|
||||
.pgt-seg-todo { background: #60a5fa; }
|
||||
.pgt-seg-bonus { background: #6b7280; }
|
||||
.pgt-seg-locked { background: #3f4451; }
|
||||
|
||||
.pgt-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
color: #b9bec9;
|
||||
}
|
||||
|
||||
.pgt-sub b { color: #e8eaed; font-variant-numeric: tabular-nums; }
|
||||
.pgt-subpct { margin-left: auto; color: #9aa0ac; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.pgt-stats {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pgt-stats li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 2px 0;
|
||||
color: #b9bec9;
|
||||
}
|
||||
|
||||
.pgt-stats b {
|
||||
margin-left: auto;
|
||||
color: #e8eaed;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pgt-dot { width: 8px; height: 8px; border-radius: 99px; flex: none; }
|
||||
.pgt-dot-done { background: #22c55e; }
|
||||
.pgt-dot-todo { background: #60a5fa; }
|
||||
.pgt-dot-bonus { background: #6b7280; }
|
||||
.pgt-dot-locked { background: #3f4451; }
|
||||
|
||||
.pgt-big {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 11px;
|
||||
padding: 7px;
|
||||
font-weight: 600;
|
||||
color: #0b1220;
|
||||
background: #60a5fa;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.pgt-big:hover { background: #93c5fd; border-color: #93c5fd; color: #0b1220; }
|
||||
|
||||
/* --- marking on the graph itself ----------------------------------------- */
|
||||
|
||||
/* The diagram lives inside svg-pan-zoom, so marks are applied to the SVG nodes
|
||||
and pan/zoom along with it. Mermaid sets the stroke inline, hence !important. */
|
||||
|
||||
.pgt-next rect.label-container {
|
||||
stroke: #60a5fa !important;
|
||||
stroke-width: 3px !important;
|
||||
stroke-dasharray: 7 4 !important;
|
||||
animation: pgt-dash 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.pgt-bonus rect.label-container {
|
||||
stroke: #6b7280 !important;
|
||||
stroke-width: 2px !important;
|
||||
stroke-dasharray: 2 3 !important;
|
||||
}
|
||||
|
||||
@keyframes pgt-dash {
|
||||
to { stroke-dashoffset: -22; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pgt-next rect.label-container { animation: none; }
|
||||
}
|
||||
|
||||
/* --- full screen viewer --------------------------------------------------- */
|
||||
|
||||
.pgt-viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #16181d;
|
||||
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.pgt-viewer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #1f2128;
|
||||
border-bottom: 1px solid #3a3d47;
|
||||
}
|
||||
|
||||
.pgt-viewer-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pgt-search {
|
||||
flex: 0 1 320px;
|
||||
min-width: 120px;
|
||||
padding: 5px 9px;
|
||||
color: #e8eaed;
|
||||
background: #16181d;
|
||||
border: 1px solid #454a56;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.pgt-search:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.pgt-search-count {
|
||||
color: #8b919d;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pgt-viewer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pgt-zoom {
|
||||
min-width: 46px;
|
||||
text-align: right;
|
||||
color: #8b919d;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pgt-close { font-size: 13px; }
|
||||
|
||||
.pgt-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, #262932 1px, transparent 0) 0 0 / 26px 26px,
|
||||
#16181d;
|
||||
}
|
||||
|
||||
.pgt-stage.pgt-dragging { cursor: grabbing; }
|
||||
|
||||
.pgt-svg-full {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Our own controls replace svg-pan-zoom's, which would sit at a stale position. */
|
||||
.pgt-viewer #svg-pan-zoom-controls { display: none; }
|
||||
|
||||
.pgt-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 7px 12px;
|
||||
background: #1f2128;
|
||||
border-top: 1px solid #3a3d47;
|
||||
color: #b9bec9;
|
||||
}
|
||||
|
||||
.pgt-legend span { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.pgt-sw {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pgt-sw-done { background: var(--required-validated, #22c55e); }
|
||||
.pgt-sw-todo { background: transparent; border: 2px solid #60a5fa; }
|
||||
.pgt-sw-bonus { background: transparent; border: 2px dotted #6b7280; }
|
||||
.pgt-sw-locked { background: #3f4451; opacity: .4; }
|
||||
|
||||
.pgt-hint {
|
||||
margin-left: auto;
|
||||
color: #6f7681;
|
||||
}
|
||||
|
||||
.pgt-placeholder {
|
||||
border: 1px dashed #3a3d47;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* --- search highlighting -------------------------------------------------- */
|
||||
|
||||
.pgt-searching .edgePaths { opacity: .1; }
|
||||
|
||||
g.node.pgt-dim { opacity: .12; }
|
||||
|
||||
g.node.pgt-hit rect.label-container {
|
||||
stroke: #f59e0b !important;
|
||||
stroke-width: 4px !important;
|
||||
stroke-dasharray: none !important;
|
||||
}
|
||||
|
||||
/* --- minimap -------------------------------------------------------------- */
|
||||
|
||||
.pgt-minimap {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 46px;
|
||||
border: 1px solid #3a3d47;
|
||||
border-radius: 8px;
|
||||
background: rgba(22, 24, 29, .92);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.pgt-minimap-view {
|
||||
fill: rgba(255, 255, 255, .10);
|
||||
stroke: #e8eaed;
|
||||
stroke-width: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- node affordances ----------------------------------------------------- */
|
||||
|
||||
.pgt-viewer g.node a { cursor: pointer; }
|
||||
|
||||
.pgt-viewer g.node:hover rect.label-container {
|
||||
filter: brightness(1.35);
|
||||
}
|
||||
|
||||
.pgt-viewer {
|
||||
animation: pgt-fade .14s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pgt-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pgt-viewer { animation: none; }
|
||||
}
|
||||
|
||||
/* --- counters inside the full screen view --------------------------------- */
|
||||
|
||||
.pgt-viewer-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pgt-viewer-score > b {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.pgt-of { color: #8b919d; }
|
||||
|
||||
.pgt-viewer-pct {
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pgt-viewer-req {
|
||||
padding-left: 9px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid #3a3d47;
|
||||
color: #b9bec9;
|
||||
}
|
||||
|
||||
.pgt-viewer-req b { color: #e8eaed; }
|
||||
|
||||
.pgt-viewer-bar-track {
|
||||
display: flex;
|
||||
width: 132px;
|
||||
height: 7px;
|
||||
flex: none;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
background: #31353f;
|
||||
}
|
||||
|
||||
.pgt-viewer-bar-track i { height: 100%; }
|
||||
|
||||
.pgt-legend b {
|
||||
margin-left: 2px;
|
||||
color: #e8eaed;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
/* Piscine Graph Tweaks — full screen graph viewer.
|
||||
*
|
||||
* The inline graph is locked in a ~200px-tall <svg> driven by svg-pan-zoom, which
|
||||
* makes a 5000px-wide diagram unreadable. Rather than fight that instance, this
|
||||
* viewer MOVES the real <svg> into a full screen overlay (a placeholder holds its
|
||||
* spot in the page), takes over the svg-pan-zoom viewport <g> transform, and
|
||||
* drives its own pan/zoom. On close everything is put back exactly as it was.
|
||||
*
|
||||
* Moving the node rather than cloning it keeps mermaid's own <style> block —
|
||||
* which lives inside the SVG and is scoped to its id — working untouched. The one
|
||||
* thing that does not survive the move is inherited CSS custom properties
|
||||
* (--required-validated and friends), so those are resolved and re-declared on
|
||||
* the overlay before the move.
|
||||
*
|
||||
* Interface: window.__pgtViewer.toggle(nodes) / .isOpen() / .close()
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (window.__pgtViewer) return;
|
||||
|
||||
const MIN_K = 0.04;
|
||||
const MAX_K = 10;
|
||||
const FIT_PAD = 0.92;
|
||||
const READABLE_K = 0.6; // below this, node labels are too small to read
|
||||
const WIDE_MAX_K = 1.4; // cap when filling the height of a very wide graph
|
||||
const EDGE_PAD = 24;
|
||||
const DRAG_SLOP = 4; // px of movement before a click counts as a drag
|
||||
const PAN_STEP = 90; // px per arrow key press
|
||||
|
||||
const MINI_W = 300;
|
||||
const MINI_H = 104;
|
||||
const MINI_PAD = 6;
|
||||
|
||||
const STATUS_COLOR = {
|
||||
done: 'var(--required-validated, #22c55e)',
|
||||
required: '#60a5fa',
|
||||
bonus: '#6b7280',
|
||||
locked: '#3f4451',
|
||||
};
|
||||
|
||||
const state = {
|
||||
open: false,
|
||||
host: null, stage: null, svg: null, vp: null, placeholder: null,
|
||||
saved: null,
|
||||
nodes: [],
|
||||
t: { x: 0, y: 0, k: 1 },
|
||||
mini: null,
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0);
|
||||
|
||||
/* The page panel sits under the overlay, so the viewer carries its own copy of
|
||||
the counters — same numbers, computed from the same node list. */
|
||||
function stats(nodes) {
|
||||
const by = (s) => nodes.filter((n) => n.status === s).length;
|
||||
const required = nodes.filter((n) => n.required);
|
||||
return {
|
||||
total: nodes.length,
|
||||
done: by('done'),
|
||||
required: by('required'),
|
||||
bonus: by('bonus'),
|
||||
locked: by('locked'),
|
||||
requiredTotal: required.length,
|
||||
requiredDone: required.filter((n) => n.validated).length,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- transform
|
||||
|
||||
function applyTransform() {
|
||||
const { x, y, k } = state.t;
|
||||
state.vp.setAttribute('transform', `translate(${x},${y}) scale(${k})`);
|
||||
const zoom = state.host.querySelector('.pgt-zoom');
|
||||
if (zoom) zoom.textContent = `${Math.round(k * 100)}%`;
|
||||
updateMinimap();
|
||||
}
|
||||
|
||||
function stageSize() {
|
||||
const r = state.stage.getBoundingClientRect();
|
||||
return { w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
/* The SVG carries no viewBox, so one user unit is one pixel and screen space
|
||||
and viewport space differ only by the transform we set ourselves.
|
||||
|
||||
mode 'all' fits the whole graph. mode 'auto' is the opening view: this graph
|
||||
is roughly 6:1, so fitting it whole shrinks the labels past readability —
|
||||
when that happens, fill the height instead and let the user pan sideways. */
|
||||
function fit(mode = 'all') {
|
||||
const box = state.vp.getBBox();
|
||||
if (!box.width || !box.height) return;
|
||||
const { w, h } = stageSize();
|
||||
|
||||
const kAll = Math.min(w / box.width, h / box.height) * FIT_PAD;
|
||||
const kHeight = (h / box.height) * FIT_PAD;
|
||||
const fillHeight = mode === 'auto' && kAll < READABLE_K && kHeight > kAll;
|
||||
|
||||
const k = clamp(fillHeight ? Math.min(kHeight, WIDE_MAX_K) : kAll, MIN_K, MAX_K);
|
||||
state.t = {
|
||||
k,
|
||||
x: fillHeight ? EDGE_PAD - box.x * k : (w - box.width * k) / 2 - box.x * k,
|
||||
y: (h - box.height * k) / 2 - box.y * k,
|
||||
};
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomAt(px, py, factor) {
|
||||
const { x, y, k } = state.t;
|
||||
const k2 = clamp(k * factor, MIN_K, MAX_K);
|
||||
if (k2 === k) return;
|
||||
const ratio = k2 / k;
|
||||
state.t = { k: k2, x: px - (px - x) * ratio, y: py - (py - y) * ratio };
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomCentre(factor) {
|
||||
const { w, h } = stageSize();
|
||||
zoomAt(w / 2, h / 2, factor);
|
||||
}
|
||||
|
||||
function panBy(dx, dy) {
|
||||
state.t.x += dx;
|
||||
state.t.y += dy;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
/** Centre a point given in viewport coordinates. */
|
||||
function centreViewport(cx, cy, k = state.t.k) {
|
||||
const { w, h } = stageSize();
|
||||
const k2 = clamp(k, MIN_K, MAX_K);
|
||||
state.t = { k: k2, x: w / 2 - cx * k2, y: h / 2 - cy * k2 };
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
/** Centre one node, converting its screen box back to viewport coordinates. */
|
||||
function centreOn(el, k = state.t.k) {
|
||||
const stage = state.stage.getBoundingClientRect();
|
||||
const box = el.getBoundingClientRect();
|
||||
centreViewport(
|
||||
(box.left + box.width / 2 - stage.left - state.t.x) / state.t.k,
|
||||
(box.top + box.height / 2 - stage.top - state.t.y) / state.t.k,
|
||||
k
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- minimap
|
||||
|
||||
/* Rather than cloning the SVG (which would duplicate mermaid's id-scoped
|
||||
styles), the minimap is redrawn from scratch: one small rect per node,
|
||||
coloured by status, plus a frame showing what is currently on screen. */
|
||||
function buildMinimap() {
|
||||
const vpCTM = state.vp.getScreenCTM();
|
||||
if (!vpCTM) return;
|
||||
const inv = vpCTM.inverse();
|
||||
|
||||
const boxes = [];
|
||||
for (const n of state.nodes) {
|
||||
const r = n.el.getBoundingClientRect();
|
||||
if (!r.width) continue;
|
||||
const p1 = new DOMPoint(r.left, r.top).matrixTransform(inv);
|
||||
const p2 = new DOMPoint(r.right, r.bottom).matrixTransform(inv);
|
||||
boxes.push({ x: p1.x, y: p1.y, w: p2.x - p1.x, h: p2.y - p1.y, status: n.status });
|
||||
}
|
||||
if (!boxes.length) return;
|
||||
|
||||
const minX = Math.min(...boxes.map((b) => b.x));
|
||||
const minY = Math.min(...boxes.map((b) => b.y));
|
||||
const maxX = Math.max(...boxes.map((b) => b.x + b.w));
|
||||
const maxY = Math.max(...boxes.map((b) => b.y + b.h));
|
||||
const scale = Math.min(
|
||||
(MINI_W - MINI_PAD * 2) / (maxX - minX),
|
||||
(MINI_H - MINI_PAD * 2) / (maxY - minY)
|
||||
);
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
svg.setAttribute('class', 'pgt-minimap');
|
||||
svg.setAttribute('width', MINI_W);
|
||||
svg.setAttribute('height', MINI_H);
|
||||
|
||||
const toMini = (x, y) => ({
|
||||
x: MINI_PAD + (x - minX) * scale,
|
||||
y: MINI_PAD + (y - minY) * scale,
|
||||
});
|
||||
|
||||
for (const b of boxes) {
|
||||
const r = document.createElementNS(ns, 'rect');
|
||||
const p = toMini(b.x, b.y);
|
||||
r.setAttribute('x', p.x);
|
||||
r.setAttribute('y', p.y);
|
||||
r.setAttribute('width', Math.max(1.5, b.w * scale));
|
||||
r.setAttribute('height', Math.max(1.5, b.h * scale));
|
||||
r.setAttribute('rx', 1);
|
||||
r.setAttribute('fill', STATUS_COLOR[b.status] || STATUS_COLOR.bonus);
|
||||
svg.appendChild(r);
|
||||
}
|
||||
|
||||
const view = document.createElementNS(ns, 'rect');
|
||||
view.setAttribute('class', 'pgt-minimap-view');
|
||||
svg.appendChild(view);
|
||||
|
||||
state.host.appendChild(svg);
|
||||
state.mini = { svg, view, scale, minX, minY, toMini };
|
||||
|
||||
svg.addEventListener('pointerdown', onMiniJump);
|
||||
svg.addEventListener('pointermove', (e) => { if (e.buttons === 1) onMiniJump(e); });
|
||||
}
|
||||
|
||||
function updateMinimap() {
|
||||
const mini = state.mini;
|
||||
if (!mini) return;
|
||||
const { w, h } = stageSize();
|
||||
const { x, y, k } = state.t;
|
||||
const tl = mini.toMini(-x / k, -y / k);
|
||||
mini.view.setAttribute('x', tl.x);
|
||||
mini.view.setAttribute('y', tl.y);
|
||||
mini.view.setAttribute('width', Math.max(3, (w / k) * mini.scale));
|
||||
mini.view.setAttribute('height', Math.max(3, (h / k) * mini.scale));
|
||||
}
|
||||
|
||||
function onMiniJump(e) {
|
||||
const mini = state.mini;
|
||||
const r = mini.svg.getBoundingClientRect();
|
||||
centreViewport(
|
||||
(e.clientX - r.left - MINI_PAD) / mini.scale + mini.minX,
|
||||
(e.clientY - r.top - MINI_PAD) / mini.scale + mini.minY
|
||||
);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ search
|
||||
|
||||
function runSearch(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
state.svg.classList.toggle('pgt-searching', q.length > 0);
|
||||
let hits = 0;
|
||||
let only = null;
|
||||
for (const n of state.nodes) {
|
||||
const hit = q.length > 0 && n.label.toLowerCase().includes(q);
|
||||
if (hit) { hits++; only = n; }
|
||||
n.el.classList.toggle('pgt-hit', hit);
|
||||
n.el.classList.toggle('pgt-dim', q.length > 0 && !hit);
|
||||
}
|
||||
state.host.querySelector('.pgt-search-count').textContent =
|
||||
q ? `${hits} match${hits === 1 ? '' : 'es'}` : '';
|
||||
if (hits === 1) centreOn(only.el, Math.max(state.t.k, 1));
|
||||
return hits;
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
state.svg.classList.remove('pgt-searching');
|
||||
for (const n of state.nodes) n.el.classList.remove('pgt-hit', 'pgt-dim');
|
||||
const counter = state.host && state.host.querySelector('.pgt-search-count');
|
||||
if (counter) counter.textContent = '';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- css variables
|
||||
|
||||
/* Custom properties are inherited, so they must be read while the SVG still
|
||||
sits in its original place, then re-declared on the overlay. */
|
||||
function carryCssVars(from, to) {
|
||||
const names = new Set();
|
||||
from.querySelectorAll('[style*="var(--"]').forEach((el) => {
|
||||
const style = el.getAttribute('style') || '';
|
||||
for (const m of style.matchAll(/var\((--[\w-]+)/g)) names.add(m[1]);
|
||||
});
|
||||
const computed = getComputedStyle(from);
|
||||
for (const name of names) {
|
||||
const value = computed.getPropertyValue(name);
|
||||
if (value) to.style.setProperty(name, value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- open
|
||||
|
||||
function buildChrome(nodes) {
|
||||
const st = stats(nodes);
|
||||
const host = document.createElement('div');
|
||||
host.className = 'pgt-viewer';
|
||||
host.innerHTML = `
|
||||
<div class="pgt-viewer-bar">
|
||||
<span class="pgt-viewer-title"></span>
|
||||
<span class="pgt-viewer-score">
|
||||
<b>${st.done}</b><span class="pgt-of">/${st.total}</span>
|
||||
<span class="pgt-viewer-pct">${pct(st.done, st.total)}%</span>
|
||||
<span class="pgt-viewer-req">required <b>${st.requiredDone}/${st.requiredTotal}</b>
|
||||
<span class="pgt-of">${pct(st.requiredDone, st.requiredTotal)}%</span></span>
|
||||
</span>
|
||||
<span class="pgt-viewer-bar-track" title="Validated / required left / bonus left / locked">
|
||||
<i class="pgt-seg-done" style="width:${pct(st.done, st.total)}%"></i>
|
||||
<i class="pgt-seg-todo" style="width:${pct(st.required, st.total)}%"></i>
|
||||
<i class="pgt-seg-bonus" style="width:${pct(st.bonus, st.total)}%"></i>
|
||||
<i class="pgt-seg-locked" style="width:${pct(st.locked, st.total)}%"></i>
|
||||
</span>
|
||||
<input class="pgt-search" type="search" placeholder="Search an exercise… (/)"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<span class="pgt-search-count"></span>
|
||||
<span class="pgt-viewer-actions">
|
||||
<span class="pgt-zoom">100%</span>
|
||||
<button class="pgt-btn" data-act="out" title="Zoom out (−)">−</button>
|
||||
<button class="pgt-btn" data-act="in" title="Zoom in (+)">+</button>
|
||||
<button class="pgt-btn" data-act="reset" title="Readable view (Home)">Reset</button>
|
||||
<button class="pgt-btn" data-act="fit" title="Fit everything (0)">Fit all</button>
|
||||
<button class="pgt-btn pgt-close" data-act="close" title="Close (Esc)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pgt-stage"></div>
|
||||
<div class="pgt-legend">
|
||||
<span><i class="pgt-sw pgt-sw-done"></i>Validated<b>${st.done}</b></span>
|
||||
<span><i class="pgt-sw pgt-sw-todo"></i>Required left<b>${st.required}</b></span>
|
||||
<span><i class="pgt-sw pgt-sw-bonus"></i>Bonus<b>${st.bonus}</b></span>
|
||||
<span><i class="pgt-sw pgt-sw-locked"></i>Locked<b>${st.locked}</b></span>
|
||||
<span class="pgt-hint">arrows or drag to pan · wheel to zoom · shift+wheel sideways · click a node to open it</span>
|
||||
</div>`;
|
||||
return host;
|
||||
}
|
||||
|
||||
function open(nodes) {
|
||||
if (state.open || !nodes.length) return;
|
||||
const svg = nodes[0].el.closest('svg');
|
||||
const vp = svg && (svg.querySelector('g.svg-pan-zoom_viewport') || svg.querySelector('g'));
|
||||
if (!svg || !vp) return;
|
||||
|
||||
state.nodes = nodes;
|
||||
state.svg = svg;
|
||||
state.vp = vp;
|
||||
|
||||
state.host = buildChrome(nodes);
|
||||
state.stage = state.host.querySelector('.pgt-stage');
|
||||
state.host.querySelector('.pgt-viewer-title').textContent =
|
||||
(nodes[0].project || 'graph').replace(/^exercises_/, 'Piscine ').replace(/\bc\b/, 'C');
|
||||
carryCssVars(svg, state.host);
|
||||
document.body.appendChild(state.host);
|
||||
|
||||
// Remember everything we are about to overwrite.
|
||||
state.saved = {
|
||||
width: svg.getAttribute('width'),
|
||||
height: svg.getAttribute('height'),
|
||||
style: svg.getAttribute('style'),
|
||||
vpTransform: vp.getAttribute('transform'),
|
||||
vpStyle: vp.getAttribute('style'),
|
||||
bodyOverflow: document.body.style.overflow,
|
||||
};
|
||||
|
||||
state.placeholder = document.createElement('div');
|
||||
state.placeholder.className = 'pgt-placeholder';
|
||||
state.placeholder.style.height = `${svg.getBoundingClientRect().height}px`;
|
||||
svg.parentNode.insertBefore(state.placeholder, svg);
|
||||
state.stage.appendChild(svg);
|
||||
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('height', '100%');
|
||||
svg.classList.add('pgt-svg-full');
|
||||
// svg-pan-zoom drives the viewport through an inline style transform, which
|
||||
// would win over the attribute we set. Clear it and own the attribute.
|
||||
vp.style.transform = '';
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
state.open = true;
|
||||
bind();
|
||||
fit('auto');
|
||||
buildMinimap();
|
||||
updateMinimap();
|
||||
state.host.querySelector('.pgt-search').focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!state.open) return;
|
||||
unbind();
|
||||
clearSearch();
|
||||
|
||||
const { svg, vp, saved } = state;
|
||||
svg.classList.remove('pgt-svg-full');
|
||||
setOrRemove(svg, 'width', saved.width);
|
||||
setOrRemove(svg, 'height', saved.height);
|
||||
setOrRemove(svg, 'style', saved.style);
|
||||
setOrRemove(vp, 'transform', saved.vpTransform);
|
||||
setOrRemove(vp, 'style', saved.vpStyle);
|
||||
|
||||
state.placeholder.replaceWith(svg);
|
||||
state.host.remove();
|
||||
document.body.style.overflow = saved.bodyOverflow;
|
||||
|
||||
state.open = false;
|
||||
state.mini = null;
|
||||
state.host = state.stage = state.svg = state.vp = state.placeholder = null;
|
||||
}
|
||||
|
||||
function setOrRemove(el, attr, value) {
|
||||
if (value === null || value === undefined) el.removeAttribute(attr);
|
||||
else el.setAttribute(attr, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ events
|
||||
|
||||
let dragging = null;
|
||||
let suppressNextClick = false;
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) { // horizontal panning, the natural gesture on a wide graph
|
||||
panBy(-e.deltaY, 0);
|
||||
return;
|
||||
}
|
||||
const r = state.stage.getBoundingClientRect();
|
||||
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0016));
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
dragging = { x: e.clientX, y: e.clientY, ox: state.t.x, oy: state.t.y, moved: false };
|
||||
state.stage.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - dragging.x;
|
||||
const dy = e.clientY - dragging.y;
|
||||
if (!dragging.moved && Math.hypot(dx, dy) < DRAG_SLOP) return;
|
||||
dragging.moved = true;
|
||||
state.stage.classList.add('pgt-dragging');
|
||||
state.t.x = dragging.ox + dx;
|
||||
state.t.y = dragging.oy + dy;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!dragging) return;
|
||||
const moved = dragging.moved;
|
||||
dragging = null;
|
||||
state.stage.classList.remove('pgt-dragging');
|
||||
try { state.stage.releasePointerCapture(e.pointerId); } catch (_) { /* already gone */ }
|
||||
if (moved) suppressNextClick = true;
|
||||
}
|
||||
|
||||
/* A drag that ends on a node must not follow the node's link. */
|
||||
function onClickCapture(e) {
|
||||
if (!suppressNextClick) return;
|
||||
suppressNextClick = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function onDblClick(e) {
|
||||
const r = state.stage.getBoundingClientRect();
|
||||
zoomAt(e.clientX - r.left, e.clientY - r.top, 1.6);
|
||||
}
|
||||
|
||||
const ARROWS = {
|
||||
ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1],
|
||||
};
|
||||
|
||||
function onKeyDown(e) {
|
||||
const typing = e.target instanceof HTMLInputElement;
|
||||
if (e.key === 'Escape') {
|
||||
if (typing && e.target.value) { e.target.value = ''; runSearch(''); }
|
||||
else close();
|
||||
return;
|
||||
}
|
||||
if (typing && e.key !== 'Enter') return;
|
||||
|
||||
const arrow = ARROWS[e.key];
|
||||
if (arrow) {
|
||||
const step = PAN_STEP * (e.shiftKey ? 3 : 1);
|
||||
panBy(arrow[0] * step, arrow[1] * step);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === '0') { fit('all'); e.preventDefault(); }
|
||||
else if (e.key === 'Home') { fit('auto'); e.preventDefault(); }
|
||||
else if (e.key === '+' || e.key === '=') { zoomCentre(1.25); e.preventDefault(); }
|
||||
else if (e.key === '-') { zoomCentre(1 / 1.25); e.preventDefault(); }
|
||||
else if (e.key === '/') { state.host.querySelector('.pgt-search').focus(); e.preventDefault(); }
|
||||
}
|
||||
|
||||
function onAction(e) {
|
||||
const btn = e.target.closest('[data-act]');
|
||||
if (!btn) return;
|
||||
({
|
||||
close,
|
||||
fit: () => fit('all'),
|
||||
reset: () => fit('auto'),
|
||||
in: () => zoomCentre(1.25),
|
||||
out: () => zoomCentre(1 / 1.25),
|
||||
})[btn.dataset.act]();
|
||||
}
|
||||
|
||||
function onResize() { fit('auto'); }
|
||||
|
||||
function bind() {
|
||||
// Capture phase everywhere so svg-pan-zoom's own listeners never see these.
|
||||
state.stage.addEventListener('wheel', onWheel, { passive: false, capture: true });
|
||||
state.stage.addEventListener('pointerdown', onPointerDown, true);
|
||||
state.stage.addEventListener('pointermove', onPointerMove, true);
|
||||
state.stage.addEventListener('pointerup', onPointerUp, true);
|
||||
state.stage.addEventListener('pointercancel', onPointerUp, true);
|
||||
state.stage.addEventListener('click', onClickCapture, true);
|
||||
state.stage.addEventListener('dblclick', onDblClick, true);
|
||||
state.host.addEventListener('click', onAction);
|
||||
state.host.querySelector('.pgt-search').addEventListener('input', (e) => runSearch(e.target.value));
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
function unbind() {
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
// The rest die with the overlay.
|
||||
}
|
||||
|
||||
window.__pgtViewer = {
|
||||
toggle(nodes) { state.open ? close() : open(nodes); },
|
||||
isOpen: () => state.open,
|
||||
close,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Rebuilds tests/harness.html from tests/fixture.json and the current sources.
|
||||
*
|
||||
* Everything is inlined rather than linked, so the harness renders identically
|
||||
* under file:// and headless screenshots, with no subresource loading rules to
|
||||
* worry about. Run it after any change to src/.
|
||||
*
|
||||
* node tests/build-harness.js && tests/shot.sh
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
|
||||
const fixturePath = path.join(__dirname, 'fixture.json');
|
||||
if (!fs.existsSync(fixturePath)) {
|
||||
console.error('missing tests/fixture.json — capture it with tools/make-fixture.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const vars = Object.entries(fixture.vars || {})
|
||||
.map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
||||
|
||||
const html = `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Piscine Graph Tweaks — harness</title>
|
||||
<style>
|
||||
:root {
|
||||
${vars}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: ${fixture.bodyBackground || '#1f2020'};
|
||||
font: 14px system-ui, sans-serif;
|
||||
color: #ccc;
|
||||
}
|
||||
.harness-note { padding: 10px 14px; color: #8b919d; }
|
||||
.graph-wrap { padding: 0 14px 40px; }
|
||||
${read('src/panel.css')}
|
||||
</style>
|
||||
|
||||
<div class="harness-note">
|
||||
harness — fixture captured ${fixture.capturedAt || '?'} from ${fixture.url || '?'}
|
||||
</div>
|
||||
<div class="graph-wrap">
|
||||
${fixture.svg}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
${read('src/viewer.js')}
|
||||
</script>
|
||||
<script>
|
||||
${read('src/content.js')}
|
||||
</script>
|
||||
<script>
|
||||
// #viewer in the URL opens the full screen view straight away, so a headless
|
||||
// screenshot can capture it without a click.
|
||||
if (location.hash === '#viewer') {
|
||||
setTimeout(() => window.__pgtViewer && window.__pgtViewer.toggle(window.__pgtNodes || []), 400);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'harness.html'), html);
|
||||
console.log('tests/harness.html written (' + html.length + ' bytes)');
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Screenshot the harness with headless Firefox.
|
||||
# tests/shot.sh [output.png] [width,height] [#hash]
|
||||
# A dedicated profile is used so it never collides with the running browser.
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
out=${1:-shot.png}
|
||||
size=${2:-1600,1000}
|
||||
hash=${3:-}
|
||||
|
||||
profile=${TMPDIR:-/tmp}/pgt-ff-profile
|
||||
mkdir -p "$profile"
|
||||
|
||||
timeout 120 firefox --headless --no-remote --profile "$profile" \
|
||||
--screenshot "$PWD/$out" --window-size "$size" \
|
||||
"file://$PWD/harness.html$hash" >/dev/null 2>&1
|
||||
|
||||
echo "$PWD/$out"
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Paste into the Firefox console (F12) on the graph page, WITHOUT the extension.
|
||||
* Prints and copies a summary of the graph, which is what is needed to adjust the
|
||||
* parser if the graph generator ever changes shape.
|
||||
*
|
||||
* Firefox may require typing "allow pasting" in the console first.
|
||||
*/
|
||||
(() => {
|
||||
const RE = /_required=(true|false)\/_validated=(true|false)\/_accessible=(true|false)\/([^"]+)"-\d+$/;
|
||||
|
||||
const nodes = [];
|
||||
const skipped = [];
|
||||
document.querySelectorAll('svg g.node').forEach((g) => {
|
||||
const id = g.getAttribute('id') || '';
|
||||
const m = id.match(RE);
|
||||
if (!m) { skipped.push(id); return; }
|
||||
const [, required, validated, accessible, path] = m;
|
||||
const seg = path.split('/');
|
||||
const link = g.querySelector('a');
|
||||
const label = g.querySelector('.nodeLabel');
|
||||
nodes.push({
|
||||
slug: seg[seg.length - 1].replace(/~/g, '-'),
|
||||
project: seg[seg.length - 2],
|
||||
label: label ? label.textContent.trim() : '',
|
||||
href: link ? link.getAttribute('href') || '' : '',
|
||||
required: required === 'true',
|
||||
validated: validated === 'true',
|
||||
accessible: accessible === 'true',
|
||||
});
|
||||
});
|
||||
|
||||
const count = (fn) => nodes.filter(fn).length;
|
||||
const report = {
|
||||
url: location.href,
|
||||
total: nodes.length,
|
||||
counts: {
|
||||
validated: count((n) => n.validated),
|
||||
requiredLeft: count((n) => !n.validated && n.accessible && n.required),
|
||||
bonusLeft: count((n) => !n.validated && n.accessible && !n.required),
|
||||
locked: count((n) => !n.validated && !n.accessible),
|
||||
},
|
||||
skippedIds: skipped, // start/end markers land here, that is expected
|
||||
nodes,
|
||||
sampleHtml: document.querySelector('svg g.node')?.outerHTML.slice(0, 900) || '',
|
||||
};
|
||||
|
||||
console.log(report);
|
||||
(navigator.clipboard?.writeText(JSON.stringify(report, null, 2)) || Promise.reject()).then(
|
||||
() => console.log('%c copied to clipboard', 'color:#22c55e'),
|
||||
() => console.log('%c clipboard refused — right-click the object above > Copy', 'color:#f59e0b')
|
||||
);
|
||||
return report;
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Paste into the Firefox console (F12) on the graph page, then save the result:
|
||||
*
|
||||
* wl-paste > tests/fixture.json
|
||||
*
|
||||
* It captures the real graph SVG plus the CSS custom properties it depends on
|
||||
* (--required-validated and friends), which are defined outside the SVG and
|
||||
* would otherwise be lost. That pair is everything needed to rebuild the page
|
||||
* offline and test the extension against it.
|
||||
*
|
||||
* Firefox may require typing "allow pasting" in the console first.
|
||||
*/
|
||||
(() => {
|
||||
const svg = document.querySelector('svg.statediagram') || document.querySelector('svg');
|
||||
if (!svg) return console.error('no graph SVG found on this page');
|
||||
|
||||
// Custom properties are inherited, so resolve them while the SVG is in place.
|
||||
const names = new Set();
|
||||
svg.querySelectorAll('[style*="var(--"]').forEach((el) => {
|
||||
for (const m of (el.getAttribute('style') || '').matchAll(/var\((--[\w-]+)/g)) names.add(m[1]);
|
||||
});
|
||||
const computed = getComputedStyle(svg);
|
||||
const vars = {};
|
||||
for (const n of names) vars[n] = computed.getPropertyValue(n).trim();
|
||||
|
||||
const fixture = {
|
||||
url: location.href,
|
||||
capturedAt: new Date().toISOString(),
|
||||
vars,
|
||||
bodyBackground: getComputedStyle(document.body).backgroundColor,
|
||||
svg: svg.outerHTML,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(fixture, null, 2);
|
||||
console.log('vars:', vars, '| svg length:', fixture.svg.length);
|
||||
(navigator.clipboard?.writeText(json) || Promise.reject()).then(
|
||||
() => console.log('%c copied — now run: wl-paste > tests/fixture.json', 'color:#22c55e'),
|
||||
() => console.log('%c clipboard refused — right-click the object above > Copy', 'color:#f59e0b')
|
||||
);
|
||||
return fixture;
|
||||
})();
|
||||
Reference in New Issue
Block a user