initial commit

This commit is contained in:
2026-09-12 14:29:44 +02:00
commit b65af3aae7
14 changed files with 1746 additions and 0 deletions
+19
View File
@@ -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
+182
View File
@@ -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.
Executable
+46
View File
@@ -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\")"
+55
View File
@@ -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
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;
};
})();
+12
View File
@@ -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

+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
})();
+66
View File
@@ -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
View File
@@ -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"
+52
View File
@@ -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;
})();
+40
View File
@@ -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;
})();