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