readfirst 0.3.0 → 0.5.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/README.md +18 -1
- package/package.json +1 -1
- package/src/cli.mjs +82 -7
- package/src/labels.mjs +102 -0
- package/src/view-city.mjs +311 -0
- package/src/view.mjs +49 -0
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Commit `.readfirst/map.json`. From then on:
|
|
|
24
24
|
npx readfirst read <file>... # before changing files
|
|
25
25
|
npx readfirst diff # everything your branch changed since main
|
|
26
26
|
npx readfirst diff --json # the same, for tools and agents
|
|
27
|
+
npx readfirst verify # run the checks the map lists for everything your branch changed (exit 1 if one fails)
|
|
27
28
|
npx readfirst fix # after moving code: place new files, drop patterns for deleted ones
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -33,7 +34,8 @@ npx readfirst fix # after moving code: place new files, drop patt
|
|
|
33
34
|
|
|
34
35
|
```md
|
|
35
36
|
Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the
|
|
36
|
-
checks it lists. Before you finish, run `npx readfirst
|
|
37
|
+
checks it lists. Before you finish, run `npx readfirst verify`: it runs the checks the map lists for everything you
|
|
38
|
+
changed; fix what fails.
|
|
37
39
|
When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.
|
|
38
40
|
```
|
|
39
41
|
|
|
@@ -46,6 +48,21 @@ npx readfirst hook --install
|
|
|
46
48
|
adds a hook to `.claude/settings.json`. Before each Edit or Write, Claude Code gets the rules, risks and checks for that
|
|
47
49
|
file as context, once per file per session. Nothing to remember, and the edit is never blocked.
|
|
48
50
|
|
|
51
|
+
### See the map
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npx readfirst view # the map as a city in one HTML page: .readfirst/local/view.html
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Groups are neighbourhoods, files are buildings (taller when more code depends on them), and traffic between
|
|
58
|
+
neighbourhoods is the imports between them. A red-lit building has a risk on it; an amber one is under a rule. Point at a
|
|
59
|
+
building to see its read list. The page loads three.js from a CDN.
|
|
60
|
+
|
|
61
|
+
Labels for yourself: `npx readfirst prompt --labels ko` (or `en`, ...) gives your agent a brief to write
|
|
62
|
+
`.readfirst/local/labels/ko.json`. It holds a translation of the map in technical terms and plain notes for people new
|
|
63
|
+
to the code. The view then has a switch between the two. `.readfirst/local/` is added to `.git/info/exclude`, so labels
|
|
64
|
+
stay on your machine and never reach the team's map.
|
|
65
|
+
|
|
49
66
|
## On every pull request (GitHub Action)
|
|
50
67
|
|
|
51
68
|
```yaml
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
// readfirst: what to read and check before changing a file.
|
|
2
2
|
// readfirst init [--agent] write a first .readfirst/map.json from the file tree (and the agent-notes line)
|
|
3
3
|
// readfirst prompt the brief for a coding agent to fill in titles, checks, risks and rules
|
|
4
|
+
// readfirst prompt --labels <lang> the brief for personal labels (a translation and plain notes) for the view
|
|
4
5
|
// readfirst check the map covers every tracked file and is well formed
|
|
5
6
|
// readfirst fix place new files and drop patterns for deleted ones, then check
|
|
6
7
|
// readfirst read <file>... the read list for files you are about to change
|
|
7
8
|
// readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
|
|
9
|
+
// readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
|
|
8
10
|
// readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
|
|
11
|
+
// readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
|
|
9
12
|
// Options: --root <dir> (default: the current git repository), --json, --limit <n>.
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
11
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
15
|
import { tmpdir } from 'node:os';
|
|
13
|
-
import { isAbsolute, join, relative } from 'node:path';
|
|
16
|
+
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
14
17
|
import { importEdges } from './imports.mjs';
|
|
15
18
|
import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
|
|
16
19
|
import { agentBrief } from './prompt.mjs';
|
|
20
|
+
import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
|
|
21
|
+
import { viewData } from './view.mjs';
|
|
22
|
+
import { cityData, viewHtmlCity } from './view-city.mjs';
|
|
17
23
|
import { emptyList, readList, toMarkdown } from './readlist.mjs';
|
|
18
24
|
|
|
19
25
|
const HELP = `readfirst — what to read and check before changing a file
|
|
@@ -24,9 +30,12 @@ const HELP = `readfirst — what to read and check before changing a file
|
|
|
24
30
|
readfirst fix place new files, drop patterns for deleted ones
|
|
25
31
|
readfirst read <file>... read list for files you are about to change
|
|
26
32
|
readfirst diff [--base <ref>] read list for files changed since <ref>
|
|
33
|
+
readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
|
|
27
34
|
readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
|
|
35
|
+
readfirst view [--out <file>] the map as a city in one HTML page
|
|
36
|
+
readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
|
|
28
37
|
|
|
29
|
-
Options: --root <dir>, --json, --limit <n>, --force (init: replace the map),
|
|
38
|
+
Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
|
|
30
39
|
--agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules)`;
|
|
31
40
|
|
|
32
41
|
function parse(argv) {
|
|
@@ -37,6 +46,7 @@ function parse(argv) {
|
|
|
37
46
|
else if (a === '--force') args.force = true;
|
|
38
47
|
else if (a === '--agent') args.agent = true;
|
|
39
48
|
else if (a === '--install') args.install = true;
|
|
49
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
40
50
|
else if (a === '-h' || a === '--help') args.help = true;
|
|
41
51
|
else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
|
|
42
52
|
else args._.push(a);
|
|
@@ -44,7 +54,8 @@ function parse(argv) {
|
|
|
44
54
|
return args;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
// stderr is captured, not shown: a repository with no commits yet makes `rev-parse HEAD` complain, and that is expected.
|
|
58
|
+
const git = (root, ...a) => execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
59
|
|
|
49
60
|
export function repoFiles(root) {
|
|
50
61
|
// Tracked files plus new files not ignored by .gitignore, so a file being added can be looked up before its commit.
|
|
@@ -79,7 +90,7 @@ function loadMapLenient(root, files, io) {
|
|
|
79
90
|
|
|
80
91
|
// The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
|
|
81
92
|
// AGENTS.md, .cursorrules), or to a new AGENTS.md when it has none. A file that already mentions readfirst is left alone.
|
|
82
|
-
export const AGENT_LINE = 'Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the checks it lists. Before you finish, run `npx readfirst
|
|
93
|
+
export const AGENT_LINE = 'Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the checks it lists. Before you finish, run `npx readfirst verify`: it runs the checks the map lists for everything you changed; fix what fails. When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.';
|
|
83
94
|
function addAgentLine(root) {
|
|
84
95
|
const notes = ['CLAUDE.md', 'AGENTS.md', '.cursorrules'].filter((f) => existsSync(join(root, f)));
|
|
85
96
|
const wrote = [];
|
|
@@ -104,6 +115,37 @@ function changedFiles(root, base) {
|
|
|
104
115
|
return { ref, files: [...names].sort() };
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
// Runs each check with the shell in the repository root, one after another, and keeps the end of the output of the ones
|
|
119
|
+
// that fail. readfirst writes nothing itself; the commands are the map's own and may write, as they would by hand.
|
|
120
|
+
export function verifyChecks(root, checks, { dryRun = false, timeout = 900, progress = () => {} } = {}) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const c of checks) {
|
|
123
|
+
const row = { run: c.run, why: c.why, for: c.for ?? [] };
|
|
124
|
+
if (dryRun) { out.push({ ...row, status: 'listed' }); continue; }
|
|
125
|
+
progress(`running ${c.run}\n`);
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const r = spawnSync(c.run, { cwd: root, shell: true, encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CI: process.env.CI ?? '1' } });
|
|
128
|
+
const seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
129
|
+
const timedOut = r.error?.code === 'ETIMEDOUT';
|
|
130
|
+
const status = timedOut ? 'timeout' : r.status === 0 ? 'pass' : 'fail';
|
|
131
|
+
const tail = status === 'pass' ? undefined : `${r.stdout ?? ''}${r.stderr ?? ''}${r.error && !timedOut ? r.error.message : ''}`.trimEnd().split('\n').slice(-30).join('\n');
|
|
132
|
+
out.push({ ...row, status, code: r.status, seconds, ...(tail !== undefined ? { output: tail } : {}) });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function verifyText(report, dryRun) {
|
|
138
|
+
if (!report.files.length) return `no files changed since ${report.base}: nothing to verify.\n`;
|
|
139
|
+
if (!report.checks.length) return `${report.files.length} changed file(s) since ${report.base}; the map lists no checks for them.\n`;
|
|
140
|
+
if (dryRun) return `checks for ${report.files.length} changed file(s) since ${report.base}:\n${report.checks.map((c) => `- ${c.run}${c.why ? ` — ${c.why}` : ''}`).join('\n')}\n`;
|
|
141
|
+
const mark = { pass: 'pass', fail: 'FAIL', timeout: 'TIMEOUT' };
|
|
142
|
+
let s = `\nverify: ${report.checks.length} check(s) for ${report.files.length} changed file(s) since ${report.base}\n`;
|
|
143
|
+
for (const c of report.checks) s += `${mark[c.status]} ${c.run} (${c.seconds}s)\n`;
|
|
144
|
+
for (const c of report.checks.filter((x) => x.status !== 'pass')) s += `\n--- ${c.run} (${c.status}${c.code != null ? `, exit ${c.code}` : ''}); last lines:\n${c.output}\n`;
|
|
145
|
+
s += report.ok ? '\nall checks passed.\n' : `\n${report.checks.filter((c) => c.status !== 'pass').length} check(s) failed.\n`;
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
|
|
107
149
|
// The read list as a few factual lines for an agent's context: rules, risks and checks, nothing else.
|
|
108
150
|
export function brief(list) {
|
|
109
151
|
const lines = [];
|
|
@@ -181,7 +223,7 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
181
223
|
mkdirSync(join(root, '.readfirst'), { recursive: true });
|
|
182
224
|
writeFileSync(at, `${JSON.stringify(map, null, 2)}\n`);
|
|
183
225
|
const checks = map.groups.find((g) => g.checks.length)?.checks ?? [];
|
|
184
|
-
io.out(`wrote ${MAP_PATH}: ${map.groups.length} groups${checks.length ? `, checks found: ${checks.map((c) => c.run).join(', ')}` : ''}.\n`);
|
|
226
|
+
io.out(`wrote ${MAP_PATH}: ${map.groups.length} group${map.groups.length === 1 ? "" : "s"}${checks.length ? `, checks found: ${checks.map((c) => c.run).join(', ')}` : ''}.\n`);
|
|
185
227
|
if (args.agent) {
|
|
186
228
|
const wrote = addAgentLine(root);
|
|
187
229
|
io.out(wrote.length ? `added the read-first line to ${wrote.join(', ')}.\n` : 'your agent notes already mention readfirst.\n');
|
|
@@ -191,7 +233,26 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
191
233
|
return 0;
|
|
192
234
|
}
|
|
193
235
|
if (cmd === 'prompt') {
|
|
194
|
-
|
|
236
|
+
const map = loadMap(root, files);
|
|
237
|
+
if (args.labels) {
|
|
238
|
+
if (!/^[a-z]{2,3}(-[A-Za-z0-9]+)?$/u.test(args.labels)) throw new Error('--labels needs a language code, e.g. ko or en');
|
|
239
|
+
io.out(labelsBrief(map, args.labels));
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
io.out(agentBrief(map));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
if (cmd === 'view') {
|
|
246
|
+
const map = loadMapLenient(root, files, io);
|
|
247
|
+
const imports = importEdges(files, readText(root));
|
|
248
|
+
keepLocal(root);
|
|
249
|
+
const out = args.out ? (isAbsolute(args.out) ? args.out : join(process.cwd(), args.out)) : join(root, LOCAL_DIR, 'view.html');
|
|
250
|
+
const labels = readLabels(root, io.err);
|
|
251
|
+
mkdirSync(join(out, '..'), { recursive: true });
|
|
252
|
+
writeFileSync(out, viewHtmlCity(cityData(viewData({ map, files, imports, name: basename(root) }), imports, labels)));
|
|
253
|
+
const langs = Object.keys(labels);
|
|
254
|
+
io.out(`wrote ${relative(process.cwd(), out) || out}${langs.length ? ` with labels: ${langs.join(', ')}` : ''}. Open it in a browser (it loads three.js from a CDN).\n`);
|
|
255
|
+
if (!langs.length) io.out('Tip: `readfirst prompt --labels ko` (or en) gives your agent a brief for a translation and plain notes, seen only on this machine.\n');
|
|
195
256
|
return 0;
|
|
196
257
|
}
|
|
197
258
|
if (cmd === 'check') {
|
|
@@ -231,5 +292,19 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
231
292
|
io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
|
|
232
293
|
return 0;
|
|
233
294
|
}
|
|
295
|
+
if (cmd === 'verify') {
|
|
296
|
+
const map = loadMapLenient(root, files, io);
|
|
297
|
+
const changed = changedFiles(root, args.base);
|
|
298
|
+
const list = changed.files.length
|
|
299
|
+
? readList({ map, files, imports: importEdges(files, readText(root)), targets: changed.files, commit, limit: 1 })
|
|
300
|
+
: emptyList(commit);
|
|
301
|
+
const timeout = Number(args.timeout ?? 900);
|
|
302
|
+
if (!(timeout > 0)) throw new Error('--timeout needs a number of seconds above 0');
|
|
303
|
+
const result = verifyChecks(root, list.checks, { dryRun: args.dryRun, timeout, progress: args.json ? () => {} : io.out });
|
|
304
|
+
const report = { format: 'readfirst/verify@1', commit, base: changed.ref, files: changed.files, ok: result.every((c) => c.status === 'pass' || c.status === 'listed'), checks: result };
|
|
305
|
+
if (args.json) io.out(`${JSON.stringify(report, null, 2)}\n`);
|
|
306
|
+
else io.out(verifyText(report, args.dryRun));
|
|
307
|
+
return report.ok ? 0 : 1;
|
|
308
|
+
}
|
|
234
309
|
throw new Error(`unknown command ${cmd}\n\n${HELP}`);
|
|
235
310
|
}
|
package/src/labels.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Labels for the map view, written by a coding agent and kept on one machine. They hold the map's text in another
|
|
2
|
+
// language ("text") and plain notes for people new to the code ("plain"). They are personal, not team knowledge: they
|
|
3
|
+
// live in .readfirst/local/, which is added to .git/info/exclude (this clone only) so they never reach the repository.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import { MAP_PATH } from './map.mjs';
|
|
8
|
+
|
|
9
|
+
export const LOCAL_DIR = '.readfirst/local';
|
|
10
|
+
export const LABELS_DIR = `${LOCAL_DIR}/labels`;
|
|
11
|
+
export const LABELS_FORMAT = 'readfirst/labels@1';
|
|
12
|
+
|
|
13
|
+
// Adds .readfirst/local/ to this clone's .git/info/exclude. Returns true when it added the line.
|
|
14
|
+
export function keepLocal(root) {
|
|
15
|
+
let at;
|
|
16
|
+
try {
|
|
17
|
+
at = execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
if (!isAbsolute(at)) at = join(root, at);
|
|
20
|
+
const line = `/${LOCAL_DIR}/`;
|
|
21
|
+
const now = existsSync(at) ? readFileSync(at, 'utf8') : '';
|
|
22
|
+
if (now.split('\n').some((l) => l.trim() === line)) return false;
|
|
23
|
+
mkdirSync(join(at, '..'), { recursive: true });
|
|
24
|
+
appendFileSync(at, `${now && !now.endsWith('\n') ? '\n' : ''}# readfirst: personal labels and views, this clone only\n${line}\n`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Every labels file under .readfirst/local/labels, by language. A file that is not valid JSON is skipped with a note.
|
|
29
|
+
export function readLabels(root, warn = () => {}) {
|
|
30
|
+
const dir = join(root, LABELS_DIR);
|
|
31
|
+
const out = {};
|
|
32
|
+
if (!existsSync(dir)) return out;
|
|
33
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
|
|
34
|
+
try {
|
|
35
|
+
const l = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
36
|
+
const lang = typeof l.lang === 'string' && l.lang ? l.lang : f.replace(/\.json$/u, '');
|
|
37
|
+
out[lang] = { text: l.text ?? {}, ...(l.plain ? { plain: { groups: l.plain.groups ?? {}, files: l.plain.files ?? {}, text: l.plain.text ?? {} } } : {}) };
|
|
38
|
+
} catch (e) { warn(`skipped ${LABELS_DIR}/${f}: ${e.message}\n`); }
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The strings of the map a labels file covers: group titles, risks, rules and every "why".
|
|
44
|
+
export function mapStrings(map) {
|
|
45
|
+
const out = new Set();
|
|
46
|
+
const add = (s) => { if (typeof s === 'string' && s.trim()) out.add(s); };
|
|
47
|
+
for (const g of map.groups) {
|
|
48
|
+
add(g.title);
|
|
49
|
+
for (const c of g.checks ?? []) add(c.why);
|
|
50
|
+
for (const r of g.risks ?? []) add(r.text);
|
|
51
|
+
}
|
|
52
|
+
for (const r of map.rules ?? []) {
|
|
53
|
+
add(r.rule); add(r.why);
|
|
54
|
+
for (const c of r.checks ?? []) add(c.why);
|
|
55
|
+
}
|
|
56
|
+
return [...out];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const NAMES = { en: 'English', ko: 'Korean', ja: 'Japanese', zh: 'Chinese', es: 'Spanish', de: 'German', fr: 'French', pt: 'Portuguese' };
|
|
60
|
+
|
|
61
|
+
export function labelsBrief(map, lang) {
|
|
62
|
+
const name = NAMES[lang] ?? lang;
|
|
63
|
+
const translate = lang !== 'en';
|
|
64
|
+
const strings = mapStrings(map);
|
|
65
|
+
return `You are writing ${LABELS_DIR}/${lang}.json: ${name} labels for the readfirst map view (\`npx readfirst view\`).
|
|
66
|
+
The file is for the person who asked for it and stays on this machine. First run \`npx readfirst view\` once: it adds
|
|
67
|
+
${LOCAL_DIR}/ to this clone's .git/info/exclude, so nothing in it can be committed. Do not commit the labels and do not
|
|
68
|
+
change ${MAP_PATH}.
|
|
69
|
+
|
|
70
|
+
Format:
|
|
71
|
+
{
|
|
72
|
+
"format": "${LABELS_FORMAT}",
|
|
73
|
+
"lang": "${lang}",${translate ? `
|
|
74
|
+
"text": { "<string from the map, exactly>": "<the same in ${name}>" },` : ''}
|
|
75
|
+
"plain": {
|
|
76
|
+
"groups": { "<group id>": { "name": "<short name>", "about": "<what this part does>" } },
|
|
77
|
+
"files": { "<path>": "<what this file does>" },
|
|
78
|
+
"text": { "<string from the map, exactly>": "<plain ${name}>" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
${translate ? `
|
|
82
|
+
"text" is the technical mode: a faithful ${name} translation of every string listed below. Keep code identifiers, file
|
|
83
|
+
paths, commands, option names and API names exactly as written, and keep technical terms (use the standard ${name} term,
|
|
84
|
+
or the original word where ${name} developers use it). Do not simplify or leave anything out.
|
|
85
|
+
` : ''}
|
|
86
|
+
"plain" is the plain mode, for someone who can read code but does not know this codebase or its jargon:
|
|
87
|
+
- Plain, factual sentences in ${name}. Say what the code does and what breaks if the rule or risk is ignored.
|
|
88
|
+
- Keep identifiers, paths and commands as written. Explain a technical term in a few words the first time it matters.
|
|
89
|
+
- No metaphors, analogies, jokes, nicknames, emoji or marketing tone. Group "name" is a short descriptive noun phrase
|
|
90
|
+
(like "${lang === 'ko' ? '핵심 라이브러리' : 'Core library'}"), not a picture.
|
|
91
|
+
- Only facts from the map or the code. If you are unsure, leave the entry out.
|
|
92
|
+
- "groups": every group. "files": only the 5 to 15 files people change most or that carry a risk or rule.
|
|
93
|
+
- "text": every string listed below.
|
|
94
|
+
|
|
95
|
+
Groups: ${map.groups.map((g) => g.id).join(', ')}.
|
|
96
|
+
|
|
97
|
+
Strings from the map (${strings.length}):
|
|
98
|
+
${strings.map((s) => `- ${JSON.stringify(s)}`).join('\n')}
|
|
99
|
+
|
|
100
|
+
When done, check the file is valid JSON and run \`npx readfirst view\` to see it.
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The map as a night city in WebGL. Groups are neighbourhoods of real city blocks on a street grid; every file is a
|
|
2
|
+
// building on a lot, taller when more depends on it. Lit windows everywhere; a red-lit building has a risk on it, an
|
|
3
|
+
// amber one is under a rule. Traffic on the streets between two neighbourhoods carries the imports between them.
|
|
4
|
+
// The page loads three.js from a CDN (pinned); the data is inlined.
|
|
5
|
+
const esc = (s) => String(s).replace(/[&<>"']/gu, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
6
|
+
const THREE = 'https://cdn.jsdelivr.net/npm/three@0.160.0';
|
|
7
|
+
|
|
8
|
+
// The page data: viewData plus the import edges between mapped files and the local labels (see labels.mjs).
|
|
9
|
+
export function cityData(data, imports, labels = {}) {
|
|
10
|
+
const inMap = new Set(Object.keys(data.files));
|
|
11
|
+
const edges = imports.filter((e) => inMap.has(e.from) && inMap.has(e.to)).map((e) => [e.from, e.to]);
|
|
12
|
+
return { ...data, edges, labels };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function viewHtmlCity(data) {
|
|
16
|
+
const json = JSON.stringify(data).replace(/</gu, '\\u003c');
|
|
17
|
+
return `<!doctype html>
|
|
18
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
19
|
+
<title>${esc(data.name)}: what to read before you change a file</title>
|
|
20
|
+
<style>
|
|
21
|
+
:root{--fg:#eef0f6;--muted:#9aa0b4;--red:#ff4757;--amber:#ffbe3d;--sky:#7ad7ff;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
22
|
+
*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-text-size-adjust:100%;overscroll-behavior:none;background:#05060a;color:var(--fg);font:14px/1.5 var(--sans)}
|
|
23
|
+
canvas{display:block;touch-action:none}
|
|
24
|
+
#top{position:absolute;left:0;right:0;top:0;display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;padding:12px 16px;background:linear-gradient(#05060ae6,#05060a00);pointer-events:none;z-index:3}
|
|
25
|
+
#top>*{pointer-events:auto}h1{font:600 15px var(--mono);margin:0}h1 span{color:var(--muted);font-weight:400}
|
|
26
|
+
.legend{display:flex;flex-wrap:wrap;gap:12px;font:12px var(--mono);color:var(--muted)}.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px;vertical-align:-1px}
|
|
27
|
+
#labels{position:absolute;inset:0;pointer-events:none;z-index:2}
|
|
28
|
+
.hood{position:absolute;transform:translate(-50%,-100%);font:700 14px var(--sans);letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;color:#fff;text-shadow:0 0 8px hsl(var(--h) 90% 60%),0 0 20px hsl(var(--h) 90% 55%),0 1px 2px #000;pointer-events:auto;cursor:pointer;padding:3px 9px;border-left:3px solid hsl(var(--h) 90% 60%);background:#05060a99;border-radius:2px}
|
|
29
|
+
.hood small{display:block;text-align:center;font:500 10px var(--mono);letter-spacing:0;text-transform:none;color:#d6dbe8;text-shadow:0 1px 2px #000}
|
|
30
|
+
.hood small .w{color:#ff8994}.hood small .r{color:var(--amber)}
|
|
31
|
+
.street{position:absolute;transform:translate(-50%,-100%);font:600 11px var(--mono);color:#e9eefb;background:#0b0e18cc;border:1px solid #2c3550;border-radius:4px;padding:1px 6px;white-space:nowrap;pointer-events:auto;cursor:pointer}
|
|
32
|
+
.panel{position:absolute;right:14px;top:58px;bottom:14px;width:360px;background:#0a0c14eb;border:1px solid #232a3d;border-radius:12px;z-index:3;backdrop-filter:blur(6px);display:none;flex-direction:column}
|
|
33
|
+
.panel.shown{display:flex}
|
|
34
|
+
#ph{display:flex;align-items:center;gap:8px;padding:12px 12px 8px 18px}#pt{flex:1;font:600 13px var(--mono);word-break:break-all;-webkit-user-select:text;user-select:text}
|
|
35
|
+
#ph button{flex:none;width:32px;height:32px;border-radius:8px;border:1px solid #39405a;background:#121624;color:#dfe5f5;font:15px var(--mono);cursor:pointer}#pm{display:none}
|
|
36
|
+
#pb{padding:0 18px 16px;overflow:auto;touch-action:pan-y;-webkit-user-select:text;user-select:text}
|
|
37
|
+
#mode{margin-left:auto}#mode+#lang{margin-left:0}#lang,#mode{font:600 12px var(--mono);border:1px solid #39405a;background:#121624e6;color:#dfe5f5;border-radius:8px;padding:5px 10px;cursor:pointer}
|
|
38
|
+
.panel .sub{color:var(--muted);font:12px var(--mono);margin-bottom:10px}
|
|
39
|
+
.panel .on{margin-top:2px;font:11px var(--mono);color:var(--muted)}.panel a.fl{color:#9fdcff;text-decoration:underline dotted;cursor:pointer;margin-right:6px}.panel .label small.mine{display:block;font:11px var(--mono);color:var(--muted);margin-bottom:3px}.panel .label{background:#151a28;border:1px solid #29304a;border-radius:6px;padding:8px 10px;margin:8px 0 4px;color:#d9e2ff}
|
|
40
|
+
.panel h3{font:600 12px var(--mono);margin:14px 0 6px;color:var(--sky)}.panel ul{margin:0;padding-left:16px}.panel li{margin:3px 0}
|
|
41
|
+
.k{color:#8be9c9;font-family:var(--mono)}.rr{color:var(--amber);font-family:var(--mono)}.w{color:#ff8994}.hint{color:var(--muted)}
|
|
42
|
+
.ctl{position:absolute;left:14px;bottom:14px;display:flex;gap:6px;z-index:3}
|
|
43
|
+
.ctl button{width:40px;height:40px;border-radius:10px;border:1px solid #39405a;background:#121624e6;color:#dfe5f5;font:16px var(--mono);cursor:pointer}
|
|
44
|
+
.ctl button:hover{border-color:var(--sky)}
|
|
45
|
+
.tip{position:absolute;left:14px;bottom:62px;font:12px var(--mono);color:var(--muted);z-index:3;pointer-events:none}
|
|
46
|
+
#err{position:fixed;inset:0;display:none;place-items:center;text-align:center;color:var(--muted);font:13px var(--mono)}
|
|
47
|
+
html.m .panel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:52px;overflow:hidden;transition:max-height .25s ease}
|
|
48
|
+
html.m .panel.open{max-height:55%}html.m #ph{padding:10px 10px 10px 14px;cursor:pointer}html.m #pt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;word-break:normal}html.m #pm{display:block}html.m .panel.open #pm{transform:rotate(180deg)}
|
|
49
|
+
html.m #pb{padding:0 14px 14px}html.m .ctl{left:auto;right:8px;bottom:auto;top:84px;flex-direction:column}html.m .ctl button{width:42px;height:42px}html.m .tip{display:none}
|
|
50
|
+
html.m #top{padding:10px 12px;gap:4px 10px}html.m .legend{font-size:11px;gap:4px 10px;order:3;width:100%}html.m .legend span:nth-child(3){display:none}html.m h1 span{display:none}
|
|
51
|
+
#ui{position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:3;transform-origin:0 0}#ui .panel,#ui .ctl{pointer-events:auto}
|
|
52
|
+
</style>
|
|
53
|
+
<script type="importmap">{"imports":{"three":"${THREE}/build/three.module.js","three/addons/":"${THREE}/examples/jsm/"}}</script>
|
|
54
|
+
</head><body>
|
|
55
|
+
<div id="ui"><div id="top"><h1>${esc(data.name)} <span data-t="sub"></span></h1>
|
|
56
|
+
<div class="legend"><span><i style="background:var(--red);box-shadow:0 0 8px var(--red)"></i><b data-t="lRisk" style="font-weight:400"></b></span><span><i style="background:var(--amber);box-shadow:0 0 8px var(--amber)"></i><b data-t="lRule" style="font-weight:400"></b></span><span><i style="background:#fff;box-shadow:0 0 6px #fff"></i><b data-t="lCar" style="font-weight:400"></b></span></div><button id="mode" hidden></button><button id="lang"></button></div>
|
|
57
|
+
<div id="labels"></div><aside class="panel" id="side"><div id="ph"><div id="pt"></div><button id="pm">▴</button><button id="px">×</button></div><div id="pb"></div></aside>
|
|
58
|
+
<div class="tip" data-t="tip"></div>
|
|
59
|
+
<div class="ctl"><button id="rl">⟲</button><button id="rr">⟳</button><button id="zo">−</button><button id="zi">+</button><button id="home">⌂</button></div></div>
|
|
60
|
+
<div id="err" data-t="err"></div>
|
|
61
|
+
<script>
|
|
62
|
+
// Some phones open a local file at desktop width (about 980px) and ignore the viewport tag; then everything on top of
|
|
63
|
+
// the city would be tiny. Scale the controls layer back up to the phone's real size, and use the phone layout.
|
|
64
|
+
function fitUi(){const ui=document.getElementById('ui');const coarse=matchMedia('(pointer:coarse)').matches;
|
|
65
|
+
const k=coarse&&innerWidth>screen.width*1.15?innerWidth/screen.width:1;window.__ui=k;
|
|
66
|
+
ui.style.transform=k===1?'':'scale('+k+')';ui.style.width=innerWidth/k+'px';ui.style.height=innerHeight/k+'px';
|
|
67
|
+
document.documentElement.classList.toggle('m',innerWidth/k<=820)}
|
|
68
|
+
fitUi();addEventListener('resize',fitUi);
|
|
69
|
+
// Keep the browser's own zoom off this page: pinch, double tap, ctrl+wheel and ctrl +/- zoom the city instead.
|
|
70
|
+
for(const t of ['gesturestart','gesturechange','gestureend'])document.addEventListener(t,e=>e.preventDefault(),{passive:false});
|
|
71
|
+
document.addEventListener('touchmove',e=>{if(e.touches.length>1)e.preventDefault()},{passive:false});
|
|
72
|
+
let lastTap=0;document.addEventListener('touchend',e=>{const n=Date.now();if(n-lastTap<320&&e.target.tagName==='CANVAS')e.preventDefault();lastTap=n},{passive:false});
|
|
73
|
+
document.addEventListener('dblclick',e=>e.preventDefault(),{passive:false});
|
|
74
|
+
addEventListener('wheel',e=>{if(e.ctrlKey||e.metaKey)e.preventDefault()},{passive:false});
|
|
75
|
+
addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&['+','=','-','_','0'].includes(e.key))e.preventDefault()});
|
|
76
|
+
</script>
|
|
77
|
+
<script type="module">
|
|
78
|
+
import * as T from 'three';
|
|
79
|
+
import { MapControls } from 'three/addons/controls/MapControls.js';
|
|
80
|
+
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
81
|
+
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
82
|
+
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
83
|
+
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
|
84
|
+
const D=${json};const LB=D.labels||{};const LL=()=>LB[lang]||{};
|
|
85
|
+
// Two modes, in any language: the map as written (technical terms kept; translated when a labels file has "text"), and
|
|
86
|
+
// plain notes ("plain"), which exist only for whoever wrote a labels file on their own machine.
|
|
87
|
+
let mode=(()=>{try{return localStorage.getItem('rf-mode')||'base'}catch{return 'base'}})();
|
|
88
|
+
const plainOn=()=>mode==='plain'&&!!LL().plain;const PL=()=>plainOn()?LL().plain:{};
|
|
89
|
+
const tr=x=>{const p=PL().text?.[x];if(p)return p;const d=LL().text?.[x];if(d)return d;const m=/^checks for (.+)$/s.exec(x);if(m){const r=tr(m[1]);if(r!==m[1])return t('checksFor')(r)}return x};
|
|
90
|
+
const gname=id=>PL().groups?.[id]?.name;
|
|
91
|
+
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'})[c]);
|
|
92
|
+
const S={en:{sub:'· what to read before you change a file',lRisk:'risk on this building',lRule:'a rule covers it',lCar:'traffic = imports between neighbourhoods',
|
|
93
|
+
tip:'drag to move · right-drag or two fingers to turn · wheel or pinch to zoom · click a neighbourhood to fly there',err:'This view needs WebGL and a connection to load three.js.',
|
|
94
|
+
rl:'turn left',rr:'turn right',zo:'zoom out',zi:'zoom in',home:'whole city',pm:'details',px:'close',lang:'한국어',
|
|
95
|
+
files:n=>n+' files',risk:n=>n+' risk',rule:n=>n+' rule',hood:'neighbourhood',bld:n=>n+' buildings',wRisk:n=>n+' with a risk',wRule:n=>n+' under a rule',
|
|
96
|
+
hint:'Point at or tap a building to see what to read before changing that file.',usedBy:n=>'used by '+n+' file'+(n>1?'s':''),
|
|
97
|
+
noLabels:'',checksFor:x=>'checks for '+x,modePlain:'Plain notes',modeBase:'Technical',mine:'plain note · only on this machine',hRisk:'Watch out',hRule:'Rules that must keep holding',hCheck:'Run these checks',hTest:'Tests that reach this code',why:'why',none:'Nothing in the map is tied to this file yet.'},
|
|
98
|
+
ko:{sub:'· 파일을 고치기 전에 읽을 것',lRisk:'위험이 있는 건물',lRule:'규칙이 걸린 건물',lCar:'차량 = 구역 사이 코드 연결',
|
|
99
|
+
tip:'끌어서 이동 · 우클릭이나 두 손가락으로 회전 · 휠이나 꼬집기로 확대 · 구역 이름을 누르면 그곳으로 이동',err:'이 화면은 WebGL과 three.js를 불러올 인터넷 연결이 필요해요.',
|
|
100
|
+
rl:'왼쪽으로 돌기',rr:'오른쪽으로 돌기',zo:'축소',zi:'확대',home:'도시 전체',pm:'자세히',px:'닫기',lang:'English',
|
|
101
|
+
files:n=>'파일 '+n+'개',risk:n=>'위험 '+n,rule:n=>'규칙 '+n,hood:'구역',bld:n=>'건물 '+n+'채',wRisk:n=>'위험 '+n,wRule:n=>'규칙 '+n,
|
|
102
|
+
hint:'건물을 가리키거나 누르면 그 파일을 고치기 전에 읽을 것이 나와요.',usedBy:n=>n+'개 파일이 사용',
|
|
103
|
+
checksFor:x=>x+' 확인',modePlain:'쉬운 설명',modeBase:'기본 설명',mine:'쉬운 설명 · 이 컴퓨터에만 있어요',noLabels:'지도 원문 그대로예요. 한국어로 보려면 에이전트에게 npx readfirst prompt --labels ko 를 주세요.',hRisk:'주의할 점',hRule:'지켜야 할 규칙',hCheck:'돌려야 할 검사',hTest:'이 코드에 닿는 테스트',why:'이유',none:'아직 지도에 이 파일과 이어진 내용이 없어요.'}};
|
|
104
|
+
let lang=(()=>{const q=new URLSearchParams(location.search).get('lang');if(q&&S[q])return q;try{const v=localStorage.getItem('rf-lang');if(v&&S[v])return v}catch{}return /^ko/i.test(navigator.language)?'ko':'en'})();
|
|
105
|
+
const t=k=>S[lang][k];
|
|
106
|
+
function applyLang(){document.documentElement.lang=lang;document.querySelectorAll('[data-t]').forEach(e=>e.textContent=t(e.dataset.t));
|
|
107
|
+
for(const id of ['rl','rr','zo','zi','home','pm','px']){const e=document.getElementById(id);e.title=t(id);e.setAttribute('aria-label',t(id))}document.getElementById('lang').textContent=t('lang');const mb=document.getElementById('mode');if(mb){mb.hidden=!LB[lang]?.plain;mb.textContent=t(plainOn()?'modeBase':'modePlain')}}
|
|
108
|
+
applyLang();
|
|
109
|
+
const weight=r=>r.rules.length*2+r.own*3+r.risks.length;
|
|
110
|
+
const status=r=>r.own?1:r.rules.length?2:0;
|
|
111
|
+
const hash=s=>{let h=2166136261;for(const c of s){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return (h>>>0)/4294967295};
|
|
112
|
+
|
|
113
|
+
// ---- City plan: each group becomes blocks of up to 3x3 lots, folder by folder. The busiest group takes the middle
|
|
114
|
+
// (downtown); each group then grows outward as one piece from the free cell nearest the middle. A few gaps become parks,
|
|
115
|
+
// and a ring of unlit-by-data blocks around it all makes the skyline.
|
|
116
|
+
const LOT=12,K=3,B=K*LOT,ST=14,P=B+ST,PER=K*K;
|
|
117
|
+
const imp=g=>Math.sqrt(g.files.length)*(1+g.files.reduce((s,f)=>s+weight(D.files[f]),0)/g.files.length);
|
|
118
|
+
const groups=[...D.groups].sort((a,b)=>imp(b)-imp(a));
|
|
119
|
+
const blocks=[];
|
|
120
|
+
for(const g of groups){
|
|
121
|
+
const byDir=new Map();for(const f of g.files){const d=f.includes('/')?f.slice(0,f.lastIndexOf('/')):'(top)';byDir.set(d,[...(byDir.get(d)??[]),f])}
|
|
122
|
+
let cur=null;
|
|
123
|
+
for(const [dir,fs] of [...byDir].sort((a,b)=>a[0].localeCompare(b[0]))){
|
|
124
|
+
const sorted=fs.sort((a,b)=>(D.files[b].users+weight(D.files[b]))-(D.files[a].users+weight(D.files[a])));
|
|
125
|
+
for(let i=0;i<sorted.length;i+=PER){const part=sorted.slice(i,i+PER);
|
|
126
|
+
if(cur&&cur.files.length+part.length<=PER&&part.length<4){cur.files.push(...part);cur.dirs.push(dir);continue}
|
|
127
|
+
cur={g:g.id,dirs:[dir],files:[...part]};blocks.push(cur)}}
|
|
128
|
+
}
|
|
129
|
+
const R=Math.ceil(Math.sqrt(blocks.length*2.4)/2)+2;const RING=8;const n=2*(R+RING)+1;
|
|
130
|
+
const taken=new Map();const key=(i,j)=>i+','+j;const d0=(i,j)=>Math.hypot(i,j*1.05);
|
|
131
|
+
const near=[];for(let i=-R;i<=R;i++)for(let j=-R;j<=R;j++)near.push([i,j]);near.sort((a,b)=>d0(...a)-d0(...b));
|
|
132
|
+
// a cell may not touch (even corner to corner) a cell of another neighbourhood: that keeps a boulevard between them
|
|
133
|
+
const clash=(c,g)=>{for(let di=-1;di<=1;di++)for(let dj=-1;dj<=1;dj++){const o=taken.get(key(c[0]+di,c[1]+dj));if(o&&o.g!==g)return true}return false};
|
|
134
|
+
let gi=0;for(const g of groups){const mine=blocks.filter(b=>b.g===g.id);const seed=near.find(c=>!taken.has(key(...c))&&!clash(c,g.id))??near.find(c=>!taken.has(key(...c)));const region=[];
|
|
135
|
+
for(const b of mine){let best=null,bd=Infinity;const cand=region.length?region.flatMap(([i,j])=>[[i+1,j],[i-1,j],[i,j+1],[i,j-1]]):[seed];
|
|
136
|
+
for(const c of cand){if(taken.has(key(...c))||clash(c,g.id)||Math.abs(c[0])>R||Math.abs(c[1])>R)continue;const d=Math.hypot(c[0]-seed[0],c[1]-seed[1])+d0(...c)*.35;if(d<bd){bd=d;best=c}}
|
|
137
|
+
if(!best)best=near.find(c=>!taken.has(key(...c))&&!clash(c,g.id))??near.find(c=>!taken.has(key(...c)));taken.set(key(...best),b);region.push(best);b.cell=best}
|
|
138
|
+
gi++}
|
|
139
|
+
const off=-(n*P)/2+ST/2;const cellXY=([i,j])=>[(i)*P-B/2,(j)*P-B/2];
|
|
140
|
+
blocks.forEach(b=>{[b.x,b.z]=cellXY(b.cell)});
|
|
141
|
+
const used=[...taken.keys()].map(k=>k.split(',').map(Number));const maxR=Math.max(...used.map(([i,j])=>Math.max(Math.abs(i),Math.abs(j))));
|
|
142
|
+
const parks=[],filler=[];
|
|
143
|
+
const maxD=Math.max(...used.map(([i,j])=>Math.hypot(i,j)));
|
|
144
|
+
for(let i=-(maxR+RING);i<=maxR+RING;i++)for(let j=-(maxR+RING);j<=maxR+RING;j++){if(taken.has(key(i,j)))continue;
|
|
145
|
+
const d=Math.hypot(i,j);const h=hash(i+':'+j);
|
|
146
|
+
if(d<=maxD-.5){if(h<.3)parks.push(cellXY([i,j]))}else if(h<.08)parks.push(cellXY([i,j]));else filler.push([...cellXY([i,j]),Math.max(1,Math.round(d-maxD+.5))])}
|
|
147
|
+
const SPAN=(2*(maxR+RING)+1)*P;
|
|
148
|
+
|
|
149
|
+
// ---- Scene
|
|
150
|
+
const renderer=new T.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
|
|
151
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.setSize(innerWidth,innerHeight);document.body.prepend(renderer.domElement);
|
|
152
|
+
const scene=new T.Scene();
|
|
153
|
+
const sky=document.createElement('canvas');sky.width=2;sky.height=256;const sg=sky.getContext('2d');const gr=sg.createLinearGradient(0,0,0,256);
|
|
154
|
+
gr.addColorStop(0,'#090b1a');gr.addColorStop(.45,'#2a1c48');gr.addColorStop(.6,'#7a3256');gr.addColorStop(.68,'#e07a48');gr.addColorStop(.7,'#1a1420');gr.addColorStop(1,'#05060a');sg.fillStyle=gr;sg.fillRect(0,0,2,256);
|
|
155
|
+
scene.background=new T.CanvasTexture(sky);scene.background.colorSpace=T.SRGBColorSpace;
|
|
156
|
+
const FOG=new T.Color('#1a1226');scene.fog=new T.Fog(FOG,SPAN*.3,SPAN*1.1);
|
|
157
|
+
const camera=new T.PerspectiveCamera(45,innerWidth/innerHeight,1,SPAN*6);
|
|
158
|
+
const controls=new MapControls(camera,renderer.domElement);controls.enableDamping=true;controls.dampingFactor=.07;controls.maxPolarAngle=Math.PI*.47;controls.minDistance=20;controls.maxDistance=SPAN*1.1;controls.zoomSpeed=1.2;controls.rotateSpeed=.6;controls.screenSpacePanning=false;
|
|
159
|
+
const cityR=(maxR+.5)*P;const HOME={pos:new T.Vector3(cityR*1.05,cityR*.62,cityR*1.3),target:new T.Vector3(0,cityR*.12,0)};camera.position.copy(HOME.pos);controls.target.copy(HOME.target);
|
|
160
|
+
|
|
161
|
+
// ground, streets, sidewalks
|
|
162
|
+
const ground=new T.Mesh(new T.PlaneGeometry(SPAN*8,SPAN*8),new T.MeshBasicMaterial({color:'#08090d'}));ground.rotation.x=-Math.PI/2;scene.add(ground);
|
|
163
|
+
const hue=new Map(groups.map((g,i)=>[g.id,(i*67+190)%360]));
|
|
164
|
+
const curb=new T.InstancedMesh(new T.BoxGeometry(1,1,1),new T.MeshBasicMaterial(),blocks.length+filler.length);
|
|
165
|
+
[...blocks.map(b=>[b.x,b.z,new T.Color().setHSL(hue.get(b.g)/360,.5,.085)]),...filler.map(([x,z])=>[x,z,'#101116'])].forEach(([x,z,c],i)=>{curb.setMatrixAt(i,new T.Matrix4().compose(new T.Vector3(x+B/2,.35,z+B/2),new T.Quaternion(),new T.Vector3(B+3,.7,B+3)));curb.setColorAt(i,new T.Color(c))});scene.add(curb);
|
|
166
|
+
// one glowing border around each whole neighbourhood (only on sides that face another neighbourhood or open ground)
|
|
167
|
+
const fence=[];for(const [k,b] of taken){const [i,j]=k.split(',').map(Number);const cx=i*P,cz=j*P,o=B/2+5;
|
|
168
|
+
for(const [di,dj] of [[1,0],[-1,0],[0,1],[0,-1]]){if(taken.get(key(i+di,j+dj))?.g===b.g)continue;fence.push([cx+di*o,cz+dj*o,di!==0,b.g])}}
|
|
169
|
+
const fenceMesh=new T.InstancedMesh(new T.BoxGeometry(1,1,1),new T.MeshBasicMaterial(),Math.max(fence.length,1));fenceMesh.count=fence.length;
|
|
170
|
+
fence.forEach(([x,z,vert,g],i)=>{fenceMesh.setMatrixAt(i,new T.Matrix4().compose(new T.Vector3(x,.9,z),new T.Quaternion(),vert?new T.Vector3(1,.5,P+1):new T.Vector3(P+1,.5,1)));fenceMesh.setColorAt(i,new T.Color().setHSL(hue.get(g)/360,.9,.6))});scene.add(fenceMesh);
|
|
171
|
+
const lo=-(maxR+RING)*P-B/2-ST/2,hi=(maxR+RING+1)*P-B/2-ST/2,lines=[];for(let v=lo;v<=hi+1;v+=P)lines.push(v);
|
|
172
|
+
const dashGeo=new T.BoxGeometry(3,.05,.35);const dashes=[];
|
|
173
|
+
for(const c of lines){for(let t=lo;t<hi;t+=8){dashes.push([c,t+4,0]);dashes.push([t+4,c,1])}}
|
|
174
|
+
const dash=new T.InstancedMesh(dashGeo,new T.MeshBasicMaterial({color:'#5e4e34'}),dashes.length);
|
|
175
|
+
dashes.forEach(([a,b,r],i)=>{dash.setMatrixAt(i,new T.Matrix4().compose(new T.Vector3(a,.06,b),new T.Quaternion().setFromAxisAngle(new T.Vector3(0,1,0),r?0:Math.PI/2),new T.Vector3(1,1,1)))});scene.add(dash);
|
|
176
|
+
const snap=v=>lo+Math.round((v-lo)/P)*P;
|
|
177
|
+
// street lamps at every corner
|
|
178
|
+
const lampPts=[];for(const x of lines)for(const z of lines){for(const [dx,dz] of [[-5,-5],[5,5]])lampPts.push(x+dx,5,z+dz)}
|
|
179
|
+
const glowTex=(()=>{const c=document.createElement('canvas');c.width=c.height=64;const g=c.getContext('2d');const r=g.createRadialGradient(32,32,0,32,32,32);r.addColorStop(0,'rgba(255,255,255,1)');r.addColorStop(.25,'rgba(255,255,255,.55)');r.addColorStop(1,'rgba(255,255,255,0)');g.fillStyle=r;g.fillRect(0,0,64,64);return new T.CanvasTexture(c)})();
|
|
180
|
+
const lampGeo=new T.BufferGeometry();lampGeo.setAttribute('position',new T.Float32BufferAttribute(lampPts,3));
|
|
181
|
+
scene.add(new T.Points(lampGeo,new T.PointsMaterial({color:'#ffb766',size:4.5,map:glowTex,transparent:true,depthWrite:false,blending:T.AdditiveBlending})));
|
|
182
|
+
// parks
|
|
183
|
+
const parkMesh=new T.InstancedMesh(new T.BoxGeometry(1,1,1),new T.MeshBasicMaterial({color:'#0f2a1c'}),Math.max(parks.length,1));
|
|
184
|
+
const trees=[];parks.forEach(([x,z],i)=>{parkMesh.setMatrixAt(i,new T.Matrix4().compose(new T.Vector3(x+B/2,.4,z+B/2),new T.Quaternion(),new T.Vector3(B,.8,B)));for(let k=0;k<9;k++){const h=hash(x+':'+z+':'+k);trees.push([x+5+((k%3)*12)+h*3,z+5+(Math.floor(k/3)*12)+hash(k+':'+x)*3,3+h*3])}});
|
|
185
|
+
parkMesh.count=parks.length;scene.add(parkMesh);
|
|
186
|
+
const treeMesh=new T.InstancedMesh(new T.ConeGeometry(1.8,1,6),new T.MeshBasicMaterial({color:'#164a2c'}),Math.max(trees.length,1));
|
|
187
|
+
trees.forEach(([x,z,h],i)=>treeMesh.setMatrixAt(i,new T.Matrix4().compose(new T.Vector3(x,.8+h/2,z),new T.Quaternion(),new T.Vector3(1,h,1))));treeMesh.count=trees.length;scene.add(treeMesh);
|
|
188
|
+
|
|
189
|
+
// ---- Buildings: one instanced mesh, windows drawn by the shader from world position.
|
|
190
|
+
const maxScore=Math.max(1,...Object.values(D.files).map(r=>r.users+weight(r)*2));
|
|
191
|
+
const bfiles=[];const bgeo=new T.BoxGeometry(1,1,1);bgeo.translate(0,.5,0);
|
|
192
|
+
const seeds=[],stats=[],sizes=[],states=[],gidx=[];const mats=[];
|
|
193
|
+
for(const b of blocks){b.files.forEach((f,k)=>{const r=D.files[f];const lx=k%K,lz=Math.floor(k/K);const h1=hash(f);const set=.9+h1*.9;
|
|
194
|
+
const w=LOT-set*2,d=LOT-set*2-hash(f+'d')*1.5;const score=(r.users+weight(r)*2)/maxScore;
|
|
195
|
+
const H=Math.max(5,7+85*Math.sqrt(score)*(.8+h1*.4)+h1*6);
|
|
196
|
+
const pos=new T.Vector3(b.x+lx*LOT+LOT/2,.7,b.z+lz*LOT+LOT/2);
|
|
197
|
+
mats.push(new T.Matrix4().compose(pos,new T.Quaternion(),new T.Vector3(w,H,d)));bfiles.push({f,pos,H,block:b});
|
|
198
|
+
seeds.push(Math.floor(h1*997));gidx.push(groups.findIndex(x=>x.id===b.g));stats.push(status(r));sizes.push(w,H,d);states.push(0)})}
|
|
199
|
+
const nData=bfiles.length;
|
|
200
|
+
for(const [x,z,ring] of filler){for(let k=0;k<PER;k++){const h1=hash(x+':'+z+':'+k);if(h1<.08)continue;const lx=k%K,lz=Math.floor(k/K);const set=.8+h1*1.2;
|
|
201
|
+
const w=LOT-set*2,d=LOT-set*2;const H=ring<=3?5+hash(k+'h'+x+z)*6*ring:14+Math.pow(hash(k+'h'+x+z),1.5)*(40+ring*20);
|
|
202
|
+
mats.push(new T.Matrix4().compose(new T.Vector3(x+lx*LOT+LOT/2,.7,z+lz*LOT+LOT/2),new T.Quaternion(),new T.Vector3(w,Math.max(5,H),d)));seeds.push(Math.floor(h1*997));gidx.push(-1);stats.push(-1);states.push(0)}}
|
|
203
|
+
const bmat=new T.ShaderMaterial({uniforms:{uTime:{value:0},uFocus:{value:-1},fogColor:{value:FOG},fogNear:{value:scene.fog.near},fogFar:{value:scene.fog.far}},
|
|
204
|
+
vertexShader:\`attribute float aGroup;varying float vGroup;attribute float aSeed;attribute float aStatus;attribute float aState;varying vec3 vPos;varying vec3 vN;varying vec3 vL;varying float vSeed;varying float vStatus;varying float vState;varying float vDepth;
|
|
205
|
+
void main(){vec4 wp=modelMatrix*instanceMatrix*vec4(position,1.);vPos=wp.xyz;vN=normalize(mat3(instanceMatrix)*normal);vL=position;vSeed=aSeed;vGroup=aGroup;vStatus=aStatus;vState=aState;vec4 mv=viewMatrix*wp;vDepth=-mv.z;gl_Position=projectionMatrix*mv;}\`,
|
|
206
|
+
fragmentShader:\`uniform float uTime;uniform float uFocus;varying float vGroup;uniform vec3 fogColor;uniform float fogNear;uniform float fogFar;varying vec3 vPos;varying vec3 vN;varying vec3 vL;varying float vSeed;varying float vStatus;varying float vState;varying float vDepth;
|
|
207
|
+
float h(vec2 p){p=mod(p,251.);return fract(sin(dot(p,vec2(12.9898,78.233)))*43758.5453);}
|
|
208
|
+
void main(){vec3 n=normalize(vN);float sd=floor(vSeed+.5);vec3 wall=vec3(.012,.013,.02)+.012*h(vec2(sd,3.));vec3 sc=vStatus>1.5?vec3(1.,.72,.22):vec3(1.,.22,.28);vec3 col;
|
|
209
|
+
if(n.y>.5){col=wall*.8;vec2 uv=vL.xz+.5;float e=min(min(uv.x,1.-uv.x),min(uv.y,1.-uv.y));
|
|
210
|
+
if(vStatus>.5){col+=sc*smoothstep(.09,.0,e)*2.6;col+=sc*.08*(.7+.3*sin(uTime*3.+sd));}else col+=vec3(.25,.3,.4)*smoothstep(.05,.0,e)*.15;}
|
|
211
|
+
else{float u=abs(n.x)>.5?vPos.z:vPos.x;float v=vPos.y-.7;vec2 c=vec2(floor(u/1.7),floor(v/2.6));vec2 f=vec2(fract(u/1.7),fract(v/2.6));
|
|
212
|
+
float win=step(.18,f.x)*step(f.x,.82)*step(.28,f.y)*step(f.y,.78);float lit=step(vStatus<-.5?.9:.5,h(c+sd*7.));
|
|
213
|
+
vec3 wc=mix(vec3(1.,.78,.46),vec3(.62,.82,1.),step(.62,h(c*1.9+sd)));if(vStatus>.5)wc=mix(wc,sc,.72);
|
|
214
|
+
float sh=abs(n.x)>.5?.72:1.;col=wall*sh+win*lit*wc*(.55+.5*h(c+3.1))*(vStatus<-.5?.28:1.)+win*(1.-lit)*vec3(.006,.008,.014);
|
|
215
|
+
if(vStatus>-.5)col+=vec3(1.,.55,.25)*.08*smoothstep(2.2,0.,v);if(vStatus>.5)col+=sc*.1*smoothstep(6.,0.,abs(v-floor(v/40.)*40.)*0.);}
|
|
216
|
+
if(vState>.5){float a=n.y>.5?max(abs(vL.x),abs(vL.z)):(abs(n.x)>.5?abs(vL.z):abs(vL.x));float rim=smoothstep(.44,.5,a)+(n.y>.5?0.:smoothstep(.985,1.,vL.y));col+=vec3(.3,.85,1.)*(rim*1.4+.02)*(vState>1.5?1.:.55);}
|
|
217
|
+
if(uFocus>-.5&&abs(vGroup-uFocus)>.5)col*=.22;float fg=smoothstep(fogNear,fogFar,vDepth);gl_FragColor=vec4(mix(col,fogColor,fg),1.);}\`});
|
|
218
|
+
const bmesh=new T.InstancedMesh(bgeo,bmat,mats.length);mats.forEach((m,i)=>bmesh.setMatrixAt(i,m));
|
|
219
|
+
bgeo.setAttribute('aGroup',new T.InstancedBufferAttribute(new Float32Array(gidx),1));bgeo.setAttribute('aSeed',new T.InstancedBufferAttribute(new Float32Array(seeds),1));bgeo.setAttribute('aStatus',new T.InstancedBufferAttribute(new Float32Array(stats),1));
|
|
220
|
+
const stateAttr=new T.InstancedBufferAttribute(new Float32Array(states),1);stateAttr.setUsage(T.DynamicDrawUsage);bgeo.setAttribute('aState',stateAttr);scene.add(bmesh);
|
|
221
|
+
// rooftop beacons on red and amber buildings
|
|
222
|
+
const bc=[],bcol=[];bfiles.forEach(b=>{const s=status(D.files[b.f]);if(!s)return;bc.push(b.pos.x,b.pos.y+b.H+2.5,b.pos.z);const c=new T.Color(s===1?'#ff3b4e':'#ffb52e');bcol.push(c.r,c.g,c.b)});
|
|
223
|
+
const bcGeo=new T.BufferGeometry();bcGeo.setAttribute('position',new T.Float32BufferAttribute(bc,3));bcGeo.setAttribute('color',new T.Float32BufferAttribute(bcol,3));
|
|
224
|
+
const beaconMat=new T.PointsMaterial({size:16,map:glowTex,vertexColors:true,transparent:true,depthWrite:false,blending:T.AdditiveBlending});scene.add(new T.Points(bcGeo,beaconMat));
|
|
225
|
+
|
|
226
|
+
// ---- Traffic: imports between neighbourhoods drive along the streets between them.
|
|
227
|
+
const owner={};blocks.forEach(b=>b.files.forEach(f=>owner[f]=b.g));
|
|
228
|
+
const centre={};for(const g of groups){const bs=blocks.filter(b=>b.g===g.id);centre[g.id]=[bs.reduce((s,b)=>s+b.x+B/2,0)/bs.length,bs.reduce((s,b)=>s+b.z+B/2,0)/bs.length,bs]}
|
|
229
|
+
const links=new Map();for(const [a,b] of D.edges){const A=owner[a],Bq=owner[b];if(!A||!Bq||A===Bq)continue;const k=[A,Bq].sort().join('|');links.set(k,(links.get(k)??0)+1)}
|
|
230
|
+
const routes=[];for(const [k,c] of links){const [A,Bq]=k.split('|');const a=centre[A],b=centre[Bq];const p0=[snap(a[0]),snap(a[1])],p2=[snap(b[0]),snap(b[1])];
|
|
231
|
+
const pts=[p0,[p2[0],p0[1]],p2];const len=Math.abs(p2[0]-p0[0])+Math.abs(p2[1]-p0[1]);if(len<1)continue;routes.push({pts,len,count:Math.min(40,4+c*3)})}
|
|
232
|
+
const cars=[];routes.forEach((r,ri)=>{for(let i=0;i<r.count;i++)cars.push({r:ri,t:hash(ri+':'+i),dir:i%2?1:-1,speed:.04+hash(i+':'+ri)*.05})});
|
|
233
|
+
const carMesh=new T.InstancedMesh(new T.BoxGeometry(2.2,.9,1.2),new T.MeshBasicMaterial({color:'#ffffff'}),Math.max(cars.length,1));carMesh.count=cars.length;
|
|
234
|
+
const carCol=new T.Color();cars.forEach((c,i)=>carMesh.setColorAt(i,carCol.set(c.dir>0?'#fff4d6':'#ff4a4a')));scene.add(carMesh);
|
|
235
|
+
const at=(r,t)=>{const s=t*r.len;const [a,b,c]=r.pts;const l1=Math.abs(b[0]-a[0]);if(s<=l1)return [a[0]+Math.sign(b[0]-a[0])*s,a[1],0];const s2=s-l1;return [b[0],b[1]+Math.sign(c[1]-b[1])*s2,1]};
|
|
236
|
+
const tmpM=new T.Matrix4(),tmpQ=new T.Quaternion(),tmpS=new T.Vector3(1,1,1),tmpP=new T.Vector3(),Y=new T.Vector3(0,1,0);
|
|
237
|
+
function moveCars(dt){cars.forEach((c,i)=>{const r=routes[c.r];c.t=(c.t+c.dir*c.speed*dt*40/r.len+1)%1;const [x,z,leg]=at(r,c.t);const lane=c.dir*2.4;
|
|
238
|
+
tmpP.set(leg?x+lane:x,.9,leg?z:z+lane);tmpQ.setFromAxisAngle(Y,leg?Math.PI/2:0);carMesh.setMatrixAt(i,tmpM.compose(tmpP,tmpQ,tmpS))});carMesh.instanceMatrix.needsUpdate=true}
|
|
239
|
+
|
|
240
|
+
// ---- Bloom
|
|
241
|
+
const composer=new EffectComposer(renderer);composer.addPass(new RenderPass(scene,camera));
|
|
242
|
+
const bloom=new UnrealBloomPass(new T.Vector2(innerWidth/2,innerHeight/2),.85,.45,.6);composer.addPass(bloom);composer.addPass(new OutputPass());
|
|
243
|
+
|
|
244
|
+
// ---- Labels: neighbourhood signs always; folder signs when the camera is close.
|
|
245
|
+
const labels=document.getElementById('labels');const tags=[];const redraws=[];const touch=matchMedia('(pointer:coarse)').matches;const narrow=()=>document.documentElement.classList.contains('m');
|
|
246
|
+
const stat=fs=>{let red=0,amber=0;for(const f of fs){const s=status(D.files[f]);if(s===1)red++;else if(s===2)amber++}return {red,amber}};
|
|
247
|
+
for(const g of groups){const [x,z,bs]=centre[g.id];const top=Math.max(...bfiles.filter(b=>b.block.g===g.id).map(b=>b.H));const s=stat(g.files);
|
|
248
|
+
const el=document.createElement('div');el.className='hood';el.style.setProperty('--h',hue.get(g.id));const draw=()=>{el.innerHTML=esc(gname(g.id)??g.id)+'<small>'+(gname(g.id)?esc(g.id)+' · ':'')+t('files')(g.files.length)+(s.red?' · <span class="w">'+t('risk')(s.red)+'</span>':'')+(s.amber?' · <span class="r">'+t('rule')(s.amber)+'</span>':'')+'</small>'};draw();redraws.push(draw);
|
|
249
|
+
el.onclick=()=>{flyTo(x,z,Math.sqrt(bs.length)*P*1.6+60);focusOn(g.id);showHood(g,true)};el.onmouseenter=()=>{if(!touch)showHood(g)};labels.appendChild(el);tags.push({el,p:new T.Vector3(x,Math.min(top,45)+12,z),near:Infinity})}
|
|
250
|
+
for(const b of blocks){const name=b.dirs[0].split('/').pop()+(b.dirs.length>1?' +'+(b.dirs.length-1):'');const top=Math.max(...bfiles.filter(x=>x.block===b).map(x=>x.H));
|
|
251
|
+
const el=document.createElement('div');el.className='street';el.textContent=name;el.title=b.dirs.join(', ');el.onclick=()=>flyTo(b.x+B/2,b.z+B/2,120);labels.appendChild(el);tags.push({el,p:new T.Vector3(b.x+B/2,top+6,b.z+B/2),near:260})}
|
|
252
|
+
const v=new T.Vector3();
|
|
253
|
+
function placeLabels(){const dist=camera.position.distanceTo(controls.target);for(const t of tags){v.copy(t.p).project(camera);const vis=v.z<1&&dist<t.near&&Math.abs(v.x)<1.1&&Math.abs(v.y)<1.1;
|
|
254
|
+
t.el.style.display=vis?'block':'none';if(vis){t.el.style.left=((v.x+1)/2*innerWidth/window.__ui)+'px';t.el.style.top=((1-v.y)/2*innerHeight/window.__ui)+'px'}}}
|
|
255
|
+
|
|
256
|
+
// ---- Panel
|
|
257
|
+
const noLabels=()=>lang!=='en'&&!LL().text?'<p class="hint">'+t('noLabels')+'</p>':'';
|
|
258
|
+
const note=x=>'<div class="label"><small class="mine">'+t('mine')+'</small>'+esc(x)+'</div>';
|
|
259
|
+
const side=document.getElementById('side'),pt=document.getElementById('pt'),pb=document.getElementById('pb');let current=null;
|
|
260
|
+
// show: true opens the panel (a click or tap); false only refreshes it if it is already showing (hover).
|
|
261
|
+
function setPanel(title,html,show){if(!show&&!side.classList.contains('shown'))return;pt.textContent=title;pb.innerHTML=html;pb.scrollTop=0;if(show){side.classList.add('shown');if(narrow())side.classList.add('open')}}
|
|
262
|
+
document.getElementById('ph').onclick=e=>{if(e.target.id==='px'){side.classList.remove('shown','open');return}if(narrow())side.classList.toggle('open')};
|
|
263
|
+
function focusOn(id){bmat.uniforms.uFocus.value=id==null?-1:groups.findIndex(x=>x.id===id)}
|
|
264
|
+
const fileLink=p=>D.files[p]?'<a class="fl" data-f="'+esc(p)+'">'+esc(p)+'</a>':'<span class="k">'+esc(p)+'</span>';
|
|
265
|
+
function showHood(g,show){current=()=>showHood(g,false);const s=stat(g.files);let h='<div class="sub">'+t('hood')+' · '+t('bld')(g.files.length)+' · '+t('wRisk')(s.red)+' · '+t('wRule')(s.amber)+'</div>'+(PL().groups?.[g.id]?.about?note(PL().groups[g.id].about):g.title?'<div class="label">'+esc(tr(g.title))+'</div>':'')+noLabels();
|
|
266
|
+
if(g.risks.length)h+='<h3>## '+t('hRisk')+'</h3><ul>'+g.risks.map(([x,fs])=>'<li class="w" title="'+esc(x)+'">'+esc(tr(x))+(fs.length?'<div class="on">'+fs.map(fileLink).join(' ')+'</div>':'')+'</li>').join('')+'</ul>';
|
|
267
|
+
if(g.rules.length)h+='<h3>## '+t('hRule')+'</h3><ul>'+g.rules.map(x=>'<li><span class="rr">'+esc(x[0])+'</span>: '+esc(tr(x[1]))+(x[2]?' <span class="hint">— '+t('why')+': '+esc(tr(x[2]))+'</span>':'')+'</li>').join('')+'</ul>';
|
|
268
|
+
if(g.checks.length)h+='<h3>## '+t('hCheck')+'</h3><ul>'+g.checks.map(x=>'<li><span class="k">'+esc(x[0])+'</span>'+(x[1]?' <span class="hint">— '+esc(tr(x[1]))+'</span>':'')+'</li>').join('')+'</ul>';
|
|
269
|
+
setPanel(gname(g.id)?gname(g.id)+' · '+g.id:g.id,h+'<p class="hint">'+t('hint')+'</p>',show)}
|
|
270
|
+
function showFile(f,show){current=()=>showFile(f,false);const r=D.files[f];let h='<div class="sub">'+esc(r.g)+(r.users?' · '+t('usedBy')(r.users):'')+'</div>';
|
|
271
|
+
if(PL().files?.[f])h+=note(PL().files[f]);h+=noLabels();
|
|
272
|
+
if(r.risks.length)h+='<h3>## '+t('hRisk')+'</h3><ul>'+r.risks.map(x=>'<li class="w" title="'+esc(x)+'">'+esc(tr(x))+'</li>').join('')+'</ul>';
|
|
273
|
+
if(r.rules.length)h+='<h3>## '+t('hRule')+'</h3><ul>'+r.rules.map(x=>'<li><span class="rr">'+esc(x[0])+'</span>: '+esc(tr(x[1]))+(x[2]?' <span class="hint">— '+t('why')+': '+esc(tr(x[2]))+'</span>':'')+'</li>').join('')+'</ul>';
|
|
274
|
+
if(r.checks.length)h+='<h3>## '+t('hCheck')+'</h3><ul>'+r.checks.map(x=>'<li><span class="k">'+esc(x[0])+'</span>'+(x[1]?' <span class="hint">— '+esc(tr(x[1]))+'</span>':'')+'</li>').join('')+'</ul>';
|
|
275
|
+
if(r.tests.length)h+='<h3>## '+t('hTest')+'</h3><ul>'+r.tests.map(x=>'<li class="k">'+esc(x)+'</li>').join('')+'</ul>';
|
|
276
|
+
if(!r.risks.length&&!r.rules.length&&!r.checks.length&&!r.tests.length)h+='<p class="hint">'+t('none')+'</p>';setPanel(f,h,show)}
|
|
277
|
+
document.getElementById('mode').onclick=()=>{mode=plainOn()?'base':'plain';try{localStorage.setItem('rf-mode',mode)}catch{}applyLang();redraws.forEach(d=>d());if(current)current()};
|
|
278
|
+
document.getElementById('lang').onclick=()=>{lang=lang==='ko'?'en':'ko';try{localStorage.setItem('rf-lang',lang)}catch{}applyLang();redraws.forEach(d=>d());if(current)current()};
|
|
279
|
+
|
|
280
|
+
// ---- Picking
|
|
281
|
+
const ray=new T.Raycaster(),mouse=new T.Vector2();let hover=-1,picked=-1;
|
|
282
|
+
const setState=(i,s)=>{if(i<0)return;stateAttr.setX(i,s);stateAttr.needsUpdate=true};
|
|
283
|
+
function pick(e){const r=renderer.domElement.getBoundingClientRect();mouse.set((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1);ray.setFromCamera(mouse,camera);const hit=ray.intersectObject(bmesh)[0];return hit&&hit.instanceId<nData?hit.instanceId:-1}
|
|
284
|
+
let downAt=null;renderer.domElement.addEventListener('pointerdown',e=>{downAt=[e.clientX,e.clientY]});
|
|
285
|
+
renderer.domElement.addEventListener('pointermove',e=>{if(e.buttons)return;const i=pick(e);if(i!==hover){if(hover!==picked)setState(hover,0);hover=i;if(i>=0&&i!==picked){setState(i,1);showFile(bfiles[i].f,false)}renderer.domElement.style.cursor=i>=0?'pointer':'grab'}});
|
|
286
|
+
function selectFile(i){setState(picked,0);picked=i;setState(i,2);showFile(bfiles[i].f,true);const b=bfiles[i];flyTo(b.pos.x,b.pos.z,Math.max(90,b.H*2.2))}
|
|
287
|
+
pb.addEventListener('click',e=>{const f=e.target.closest('a.fl')?.dataset.f;if(!f)return;const i=bfiles.findIndex(b=>b.f===f);if(i>=0)selectFile(i)});
|
|
288
|
+
renderer.domElement.addEventListener('pointerup',e=>{if(!downAt||Math.hypot(e.clientX-downAt[0],e.clientY-downAt[1])>5)return;const i=pick(e);if(i<0){side.classList.remove('open');focusOn(null);return}setState(picked,0);picked=i;setState(i,2);showFile(bfiles[i].f,true);const b=bfiles[i];flyTo(b.pos.x,b.pos.z,Math.max(90,b.H*2.2))});
|
|
289
|
+
|
|
290
|
+
// ---- Camera moves
|
|
291
|
+
let fly=null;function flyTo(x,z,dist){const tgt=new T.Vector3(x,0,z);const dir=camera.position.clone().sub(controls.target);dir.y=0;dir.normalize();dir.y=1.05;dir.normalize();fly={t:0,from:camera.position.clone(),to:tgt.clone().add(dir.multiplyScalar(dist)),ft:controls.target.clone(),tt:tgt}}
|
|
292
|
+
const ease=t=>t<.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
|
|
293
|
+
function turn(a){const o=camera.position.clone().sub(controls.target);o.applyAxisAngle(Y,a);fly={t:0,from:camera.position.clone(),to:controls.target.clone().add(o),ft:controls.target.clone(),tt:controls.target.clone()}}
|
|
294
|
+
document.getElementById('rl').onclick=()=>turn(-Math.PI/6);document.getElementById('rr').onclick=()=>turn(Math.PI/6);
|
|
295
|
+
const zoom=k=>{const o=camera.position.clone().sub(controls.target).multiplyScalar(k);fly={t:0,from:camera.position.clone(),to:controls.target.clone().add(o),ft:controls.target.clone(),tt:controls.target.clone()}};
|
|
296
|
+
document.getElementById('zi').onclick=()=>zoom(.7);document.getElementById('zo').onclick=()=>zoom(1.4);
|
|
297
|
+
document.getElementById('home').onclick=()=>{focusOn(null);fly={t:0,from:camera.position.clone(),to:HOME.pos.clone(),ft:controls.target.clone(),tt:HOME.target.clone()}};
|
|
298
|
+
|
|
299
|
+
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);composer.setSize(innerWidth,innerHeight);bloom.resolution.set(innerWidth/2,innerHeight/2)});
|
|
300
|
+
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
301
|
+
const clock=new T.Clock();
|
|
302
|
+
function frame(){const dt=Math.min(clock.getDelta(),.05);bmat.uniforms.uTime.value+=dt;beaconMat.opacity=.65+.35*Math.sin(bmat.uniforms.uTime.value*3);
|
|
303
|
+
if(!reduced)moveCars(dt);
|
|
304
|
+
if(fly){fly.t=Math.min(1,fly.t+dt/.9);const k=ease(fly.t);camera.position.lerpVectors(fly.from,fly.to,k);controls.target.lerpVectors(fly.ft,fly.tt,k);if(fly.t>=1)fly=null}
|
|
305
|
+
controls.update();composer.render();placeLabels();requestAnimationFrame(frame)}
|
|
306
|
+
moveCars(0);frame();window.__city=1;
|
|
307
|
+
const first=bfiles.map((b,i)=>[i,weight(D.files[b.f])]).sort((a,b)=>b[1]-a[1])[0];if(first){picked=first[0];setState(picked,2);showFile(bfiles[picked].f,!narrow())}
|
|
308
|
+
</script>
|
|
309
|
+
<script>setTimeout(()=>{if(!window.__city)document.getElementById('err').style.display='grid'},12000)</script>
|
|
310
|
+
</body></html>`;
|
|
311
|
+
}
|
package/src/view.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// What the map view needs, per file: its group, its read list in short form, and its marks. The page itself is drawn
|
|
2
|
+
// by view-city.mjs.
|
|
3
|
+
import { groupOf, matcher } from './map.mjs';
|
|
4
|
+
import { readList } from './readlist.mjs';
|
|
5
|
+
|
|
6
|
+
// What the page needs, per file: its group, its read list in short form, and its marks.
|
|
7
|
+
export function viewData({ map, files, imports, name = 'repository', limit = 5 }) {
|
|
8
|
+
const placed = files.filter((f) => groupOf(map, f));
|
|
9
|
+
const byGroup = new Map(map.groups.map((g) => [g.id, []]));
|
|
10
|
+
const fileRows = {};
|
|
11
|
+
for (const f of placed) {
|
|
12
|
+
const g = groupOf(map, f);
|
|
13
|
+
byGroup.get(g.id).push(f);
|
|
14
|
+
const list = readList({ map, files, imports, targets: [f], limit });
|
|
15
|
+
fileRows[f] = {
|
|
16
|
+
g: g.id,
|
|
17
|
+
rules: list.rules.map((r) => [r.id, r.rule, r.why ?? '']),
|
|
18
|
+
risks: list.risks.map((r) => r.text),
|
|
19
|
+
checks: list.checks.map((c) => [c.run, c.why ?? '']),
|
|
20
|
+
tests: list.tests.map((t) => t.path),
|
|
21
|
+
users: list.usedBy.length + list.usedByIndirectly,
|
|
22
|
+
// A risk "on" a file: one that names the file, not one that covers its whole group.
|
|
23
|
+
own: (g.risks ?? []).filter((r) => r.files && matcher(r.files)(f)).length,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const links = new Map();
|
|
27
|
+
for (const e of imports) {
|
|
28
|
+
const a = fileRows[e.from]?.g; const b = fileRows[e.to]?.g;
|
|
29
|
+
if (!a || !b || a === b) continue;
|
|
30
|
+
const key = [a, b].sort().join('\0');
|
|
31
|
+
links.set(key, (links.get(key) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
links: [...links].map(([k, n]) => { const [a, b] = k.split('\0'); return { a, b, n }; }),
|
|
36
|
+
groups: map.groups.map((g) => {
|
|
37
|
+
const mine = byGroup.get(g.id) ?? [];
|
|
38
|
+
return {
|
|
39
|
+
id: g.id, title: g.title, files: mine,
|
|
40
|
+
// The group's own risks (with the files they name, if any), its checks, and every rule that covers one of its files.
|
|
41
|
+
risks: (g.risks ?? []).map((r) => [r.text, r.files ?? []]),
|
|
42
|
+
checks: (g.checks ?? []).map((c) => [c.run, c.why ?? '']),
|
|
43
|
+
rules: (map.rules ?? []).filter((r) => mine.some(matcher(r.files))).map((r) => [r.id, r.rule, r.why ?? '']),
|
|
44
|
+
};
|
|
45
|
+
}).filter((g) => g.files.length),
|
|
46
|
+
rules: (map.rules ?? []).length,
|
|
47
|
+
files: fileRows,
|
|
48
|
+
};
|
|
49
|
+
}
|