@liggi/figkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +273 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/THIRD_PARTY_NOTICES.md +632 -0
- package/bin/fig.mjs +124 -0
- package/dist/viz.iife.js +59 -0
- package/examples/assets/settings-page.html +14 -0
- package/examples/assets/settings-page.png +0 -0
- package/examples/boundary.html +25 -0
- package/examples/charts/bars.json +8 -0
- package/examples/charts/before-after.json +16 -0
- package/examples/charts/dumbbell.json +9 -0
- package/examples/charts/series.json +46 -0
- package/examples/charts/split.json +10 -0
- package/examples/consolidate.html +29 -0
- package/examples/custom-bar.html +25 -0
- package/examples/flow.html +50 -0
- package/examples/grouping.html +33 -0
- package/examples/line-chart.html +25 -0
- package/examples/screenshot-notes.html +16 -0
- package/examples/sides.html +35 -0
- package/examples/split.html +13 -0
- package/examples/timeline.html +33 -0
- package/package.json +55 -4
- package/runtime/fig.css +182 -0
- package/runtime/fig.js +573 -0
- package/viz/build.mjs +27 -0
- package/viz/draw.js +227 -0
- package/viz/index.js +2 -0
- package/viz/spec.js +132 -0
package/bin/fig.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* figkit <file.html> [--out file.png] render a figure to a PNG, with a phone layout when it has sides, a phone-inline preview and a list of problems
|
|
4
|
+
* figkit <chart.json> [--out file.png] check a chart spec and render it the same way
|
|
5
|
+
* figkit guide print the guide for writing figures
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { chromium } from 'playwright-core';
|
|
12
|
+
import { ensureBuilt } from '../viz/build.mjs';
|
|
13
|
+
import { parseSpec } from '../viz/spec.js';
|
|
14
|
+
|
|
15
|
+
const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const url = (...p) => pathToFileURL(path.join(HOME, ...p)).href;
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
|
20
|
+
console.log('Usage:\n figkit <file.html> [--out file.png] render, check and preview a figure\n figkit <chart.json> [--out file.png] the same for a chart from a spec\n figkit guide how to write one');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (args[0] === 'guide') {
|
|
24
|
+
process.stdout.write(fs.readFileSync(path.join(HOME, 'GUIDE.md'), 'utf8'));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let src, out;
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
if (args[i] === '--out') out = args[++i];
|
|
31
|
+
else src = args[i];
|
|
32
|
+
}
|
|
33
|
+
src = path.resolve(src);
|
|
34
|
+
if (!fs.existsSync(src)) { console.error(`figkit: no file at ${src}`); process.exit(1); }
|
|
35
|
+
let body = fs.readFileSync(src, 'utf8');
|
|
36
|
+
if (/\.json$/i.test(src)) {
|
|
37
|
+
const spec = parseSpec(body);
|
|
38
|
+
if (!spec.ok) {
|
|
39
|
+
console.log(`This chart spec has ${spec.problems.length} problem${spec.problems.length === 1 ? '' : 's'}:\n${spec.problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
body = `<main class="fig chart"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
|
|
43
|
+
} else if (!/class="[^"]*\bfig\b/.test(body)) body = `<main class="fig">\n${body}\n</main>`;
|
|
44
|
+
await ensureBuilt();
|
|
45
|
+
|
|
46
|
+
const fonts = ['newsreader/400.css', 'newsreader/400-italic.css', 'geist-sans/400.css', 'geist-sans/500.css', 'geist-sans/600.css', 'geist-mono/400.css', 'geist-mono/500.css']
|
|
47
|
+
.map((f) => `<link rel="stylesheet" href="${url('node_modules/@fontsource', f)}">`).join('\n');
|
|
48
|
+
const page_html = `<!doctype html><html><head><meta charset="utf-8">
|
|
49
|
+
<base href="${pathToFileURL(path.dirname(src) + path.sep).href}">
|
|
50
|
+
${fonts}
|
|
51
|
+
<link rel="stylesheet" href="${url('runtime/fig.css')}">
|
|
52
|
+
<script src="${url('node_modules/d3/dist/d3.min.js')}"></script>
|
|
53
|
+
<script src="${url('node_modules/@observablehq/plot/dist/plot.umd.min.js')}"></script>
|
|
54
|
+
<script src="${url('dist/viz.iife.js')}"></script>
|
|
55
|
+
<script src="${url('runtime/fig.js')}"></script>
|
|
56
|
+
<style>body { display: inline-block; }</style>
|
|
57
|
+
</head><body>
|
|
58
|
+
${body}
|
|
59
|
+
</body></html>`;
|
|
60
|
+
|
|
61
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'figkit-'));
|
|
62
|
+
const base = path.basename(src).replace(/\.[^.]+$/, '');
|
|
63
|
+
const png = path.resolve(out ?? path.join(dir, `${base}.png`));
|
|
64
|
+
const stem = png.replace(/\.png$/i, '');
|
|
65
|
+
// The phone layout, written only for a figure with sides, for a viewer to show on a narrow screen. The inline preview
|
|
66
|
+
// is for looking at, and has a different name so no viewer mistakes it for a layout.
|
|
67
|
+
const phonePng = stem + '.phone.png', inline = stem + '-inline.png';
|
|
68
|
+
const wrapped = path.join(dir, `${base}.page.html`);
|
|
69
|
+
fs.writeFileSync(wrapped, page_html);
|
|
70
|
+
|
|
71
|
+
/** Playwright's own Chromium if installed, else any headless shell Playwright has downloaded, else Chrome or Edge. */
|
|
72
|
+
async function launch() {
|
|
73
|
+
const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || { darwin: path.join(os.homedir(), 'Library/Caches/ms-playwright'),
|
|
74
|
+
win32: path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright') }[process.platform] || path.join(os.homedir(), '.cache/ms-playwright');
|
|
75
|
+
const ls = (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []);
|
|
76
|
+
const shells = ls(cache).filter((d) => d.startsWith('chromium_headless_shell-')).sort().reverse()
|
|
77
|
+
.flatMap((d) => ls(path.join(cache, d)).map((sub) => path.join(cache, d, sub, process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell')));
|
|
78
|
+
const tries = [{}, ...shells.map((executablePath) => ({ executablePath })), { channel: 'chrome' }, { channel: 'msedge' }];
|
|
79
|
+
for (const opt of tries) {
|
|
80
|
+
if (opt.executablePath && !fs.existsSync(opt.executablePath)) continue;
|
|
81
|
+
try { return await chromium.launch(opt); } catch { /* try the next browser */ }
|
|
82
|
+
}
|
|
83
|
+
console.error('figkit: no browser found. Install Chrome, or run: npx playwright install chromium');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const browser = await launch();
|
|
88
|
+
try {
|
|
89
|
+
async function render(phone, shot) {
|
|
90
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 2 });
|
|
91
|
+
const errors = [];
|
|
92
|
+
page.on('pageerror', (e) => errors.push(e.message));
|
|
93
|
+
if (phone) await page.addInitScript(() => { window.__figPhone = true; });
|
|
94
|
+
await page.goto(pathToFileURL(wrapped).href);
|
|
95
|
+
await page.waitForFunction('window.__figDone === true', null, { timeout: 30000 });
|
|
96
|
+
const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!document.querySelector('.fig .sides') }));
|
|
97
|
+
await page.locator('.fig').first().screenshot({ path: shot });
|
|
98
|
+
await page.close();
|
|
99
|
+
return { ...r, problems: [...errors.map((e) => `Script error: ${e}`), ...r.problems] };
|
|
100
|
+
}
|
|
101
|
+
const desk = await render(false, png);
|
|
102
|
+
const mob = desk.sides ? await render(true, phonePng) : null;
|
|
103
|
+
if (!mob) fs.rmSync(phonePng, { force: true });
|
|
104
|
+
const small = await browser.newPage({ viewport: { width: 358, height: 400 }, deviceScaleFactor: 2 });
|
|
105
|
+
await small.setContent(`<body style="margin:0"><img src="data:image/png;base64,${fs.readFileSync(mob ? phonePng : png).toString('base64')}" style="width:358px;display:block"></body>`);
|
|
106
|
+
await small.waitForFunction(() => document.images[0].complete && document.images[0].naturalWidth > 0);
|
|
107
|
+
await small.locator('img').screenshot({ path: inline });
|
|
108
|
+
|
|
109
|
+
const { info } = desk;
|
|
110
|
+
const lines = [`Figure: ${png} (${info.width * 2}×${info.height * 2})`];
|
|
111
|
+
if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), the sides stacked, for a viewer to show on a phone`);
|
|
112
|
+
lines.push(`Phone inline: ${inline} (preview only)`);
|
|
113
|
+
const ph = (mob ?? desk).info.phone;
|
|
114
|
+
const sizes = [['the title', ph?.title], ['headings', ph?.heading], ['body', ph?.body]].filter(([, v]) => v != null).map(([k, v]) => `${k} ${v}px`);
|
|
115
|
+
if (sizes.length) lines.push(`Inline on a phone: ${sizes.join(', ')}; under about 6px only reads after a tap to zoom.`);
|
|
116
|
+
if (!mob && info.unmarked) lines.push('Its columns sit side by side and will shrink on a phone. If they are two sides to compare, add class "sides" to the row or grid that holds them, and a stacked phone layout is drawn too.');
|
|
117
|
+
const all = [...desk.problems, ...(mob?.problems ?? []).map((p) => `Phone layout: ${p}`)];
|
|
118
|
+
lines.push(all.length ? `\n${all.length} problem${all.length === 1 ? '' : 's'}:` : '\nNo problems found by the checks.');
|
|
119
|
+
for (const p of all) lines.push(` - ${p}`);
|
|
120
|
+
lines.push('', `The checks cannot see whether it is clear. Open the figure${mob ? ', the phone layout' : ''} and the inline preview and look as a reader would, then embed the figure: .`);
|
|
121
|
+
console.log(lines.join('\n'));
|
|
122
|
+
} finally {
|
|
123
|
+
await browser.close();
|
|
124
|
+
}
|