@bongos/core 1.19.1012 → 1.19.1014
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/.bongos-core.json +113 -78
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
- package/docs/copy-inventory.md +120 -66
- package/docs/copy-registry.json +984 -498
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet-costs.js +80 -0
- package/modules/hall-ui/public/fleet.html +64 -0
- package/modules/hall-ui/public/fleet.js +45 -0
- package/modules/hall-ui/public/fleet.states.json +14 -0
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea-objects.js +4 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/hall-ui/public/sky-composer.js +155 -0
- package/modules/hall-ui/public/sky-data.js +287 -0
- package/modules/hall-ui/public/sky-draw.js +244 -0
- package/modules/hall-ui/public/sky-interaction.js +137 -0
- package/modules/hall-ui/public/sky-layout.js +81 -0
- package/modules/hall-ui/public/sky-panel.js +141 -0
- package/modules/hall-ui/public/sky-tour.js +80 -0
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +203 -267
- package/modules/hall-ui/public/thinking.html +119 -58
- package/modules/hall-ui/public/thinking.states.json +33 -9
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth.js +8 -0
- package/src/bongos/routes/me.js +4 -1
- package/src/bongos/serve-internal.js +25 -6
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +3 -2
- package/tests/fleet_cost_view.mjs +189 -0
- package/tests/hall_audit.mjs +27 -0
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_page_gate_map.mjs +13 -5
- package/tests/hall_sky.mjs +393 -405
- package/tests/hall_thinking_room.mjs +274 -296
- package/tests/helpers.mjs +185 -217
- package/tests/ideator_large_dead_thought_proof.mjs +33 -25
- package/tests/ideator_traceable_proof.mjs +61 -39
- package/tests/nav_permission_atoms.mjs +1 -1
- package/modules/hall-ui/public/sky.css +0 -365
- package/modules/hall-ui/public/sky.html +0 -79
- package/modules/hall-ui/public/sky.js +0 -580
- package/modules/hall-ui/public/sky.states.json +0 -20
- package/modules/hall-ui/public/thinking.js +0 -418
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// modules/hall-ui/public/sky-composer.js — THE SKY, part 6 of 7: the composer
|
|
2
|
+
// (task 1004231 / sky part 3 of 5; ADR 0331).
|
|
3
|
+
//
|
|
4
|
+
// The prototype's "the composer" section, wired to the ledger. Three things
|
|
5
|
+
// the prototype guessed are read for real here, and nothing else changed:
|
|
6
|
+
//
|
|
7
|
+
// • GRAVITY — "the goal that catches it lights as you type" — is the real
|
|
8
|
+
// landing verdict, not a keyword match: the goal suggester (GET
|
|
9
|
+
// /goals/suggestions, ranked per building version, the hall composer's own
|
|
10
|
+
// read) names the likeliest well, and POST /inbox/preview-landing says what
|
|
11
|
+
// the matrix would do with it. The sentence READS that verdict; it decides
|
|
12
|
+
// nothing (ideas.js's rule: never re-derive the landing matrix here).
|
|
13
|
+
// • FILING is POST /inbox exactly as the hall form files — quick with the
|
|
14
|
+
// line (`title`, `body_md`, `kind`, and the goal the preview named), full
|
|
15
|
+
// with the template's fields (`grade: 'full'`, `template_key`,
|
|
16
|
+
// `field_values`) — so the record is identical to the form's.
|
|
17
|
+
// • THE FIVE QUESTIONS are the template's (GET /inbox/template, base-v2),
|
|
18
|
+
// asked in order after the line with skip and cancel, never a copy kept
|
|
19
|
+
// here (tests/idea_hall_composer.mjs's rule).
|
|
20
|
+
//
|
|
21
|
+
// The nod runs the existing promote path (POST /tasks/:id/promote on the task
|
|
22
|
+
// the routed filing became); the panel draws its button only for a holder of
|
|
23
|
+
// `idea.triage`.
|
|
24
|
+
|
|
25
|
+
(() => {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const SKY = window.OTBSky; const S = SKY.S;
|
|
29
|
+
const { $, NOW, esc, short, api, API } = SKY;
|
|
30
|
+
|
|
31
|
+
const IDLE = 'gravity — the goal that catches it lights as you type; letting it float is a real answer';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- gravity
|
|
34
|
+
// A monotonic token so a slow answer to an older draft never paints over a
|
|
35
|
+
// newer one (ideas.js's gravitySeq, for the same reason).
|
|
36
|
+
let gravitySeq = 0, gravityTimer = null;
|
|
37
|
+
S.gravityVerdict = null;
|
|
38
|
+
function gravitySentence(v) {
|
|
39
|
+
if (!v || !v.outcome) return 'nothing catches it — it will float in open space until something does';
|
|
40
|
+
const name = v.goal ? esc(v.goal.title || `goal ${v.goal.id}`) : '';
|
|
41
|
+
if (v.outcome === 'refused') return esc(v.refusal && v.refusal.reason ? v.refusal.reason : `${v.goal ? v.goal.title : 'that goal'} will not take this filing.`);
|
|
42
|
+
if (v.outcome === 'routed') return `gravity · <b class="hot">${name}</b> would catch it — a task in its well, ${v.status === 'ready' ? 'claimable straight away' : 'waiting for a nod'}`;
|
|
43
|
+
if (v.outcome === 'routed_home') return `gravity · <b class="hot">${esc(v.version_id || '')}</b> would catch it — a task in its maintenance goal`;
|
|
44
|
+
return 'nothing catches it — it will float in open space until something does';
|
|
45
|
+
}
|
|
46
|
+
async function suggestGoal(title) {
|
|
47
|
+
const ids = S.buildingVersionIds; if (!ids.length) return null;
|
|
48
|
+
const answers = await Promise.all(ids.map((v) => api.request('GET', `${API}/goals/suggestions?version=${encodeURIComponent(v)}&title=${encodeURIComponent(title)}&kind=feature&limit=1`).catch(() => null)));
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const r of answers) { const s = r && r.ok && r.data && Array.isArray(r.data.suggestions) ? r.data.suggestions[0] : null; if (s && (!best || s.score > best.score)) best = s; }
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
53
|
+
async function refreshGravity() {
|
|
54
|
+
const seq = ++gravitySeq; const v = $('thought').value.trim();
|
|
55
|
+
if (!v || full.step >= 0) return;
|
|
56
|
+
const goal = await suggestGoal(v); if (seq !== gravitySeq) return;
|
|
57
|
+
const body = { kind: 'feature' }; if (goal) body.goal_id = Number(goal.id); if (full.on) body.grade = 'full';
|
|
58
|
+
const r = await api.request('POST', `${API}/inbox/preview-landing`, { body }).catch(() => null); if (seq !== gravitySeq) return;
|
|
59
|
+
const verdict = r && r.ok ? r.data : null;
|
|
60
|
+
S.gravityVerdict = verdict;
|
|
61
|
+
const caught = verdict && verdict.outcome === 'routed' && verdict.goal ? (S.goalById.get(String(verdict.goal.id)) || { id: String(verdict.goal.id), title: verdict.goal.title }) : null;
|
|
62
|
+
S.gravityGoal = caught; S.dirty = true;
|
|
63
|
+
$('gravity').innerHTML = gravitySentence(verdict);
|
|
64
|
+
}
|
|
65
|
+
$('thought').addEventListener('input', () => {
|
|
66
|
+
if (full.step >= 0) return; const v = $('thought').value.trim();
|
|
67
|
+
$('file').classList.toggle('live', v.length > 0);
|
|
68
|
+
clearTimeout(gravityTimer);
|
|
69
|
+
if (!v) { gravitySeq++; S.gravityGoal = null; S.gravityVerdict = null; S.dirty = true; $('gravity').textContent = IDLE; return; }
|
|
70
|
+
gravityTimer = setTimeout(refreshGravity, 300);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- the five questions
|
|
74
|
+
// THE FIVE QUESTIONS are the template's (base-v2), asked in order after the line. Quick is the
|
|
75
|
+
// baseline: one line, nothing more. Full is a choice made before the line is filed, never after.
|
|
76
|
+
function fields() { const t = S.template; return t ? t.fields.map((f) => ({ key: f.key, label: f.label, floor: Number(f.substance_min) || 0 })) : []; }
|
|
77
|
+
const full = { on: false, step: -1, title: '', answers: {} };
|
|
78
|
+
const QUICK_HINT = 'release a thought — one line', FULL_HINT = 'release a thought — one line, then five questions';
|
|
79
|
+
function resetFull() { full.step = -1; full.title = ''; full.answers = {}; $('fullSteps').hidden = true; $('thought').placeholder = full.on ? FULL_HINT : QUICK_HINT; $('fileGo').textContent = 'file'; }
|
|
80
|
+
$('fullToggle').addEventListener('click', () => { full.on = !full.on; $('fullToggle').setAttribute('aria-pressed', String(full.on)); resetFull(); $('thought').focus(); if ($('thought').value.trim()) { clearTimeout(gravityTimer); gravityTimer = setTimeout(refreshGravity, 300); } });
|
|
81
|
+
function showStepPrompt() {
|
|
82
|
+
const FIELDS = fields(); const f = FIELDS[full.step]; $('fullSteps').hidden = false;
|
|
83
|
+
$('fullSteps').innerHTML = `<b>full idea · ${full.step + 1} of ${FIELDS.length}</b><span>${esc(f.label)}</span><span class="hint">~${f.floor} characters</span><button type="button" class="skip" id="stepSkip">skip</button><button type="button" class="skip" id="stepCancel">cancel</button>`;
|
|
84
|
+
$('thought').placeholder = f.label; $('fileGo').textContent = full.step === FIELDS.length - 1 ? 'file the full idea' : 'next';
|
|
85
|
+
$('stepSkip').addEventListener('click', () => advance('')); $('stepCancel').addEventListener('click', cancelFull);
|
|
86
|
+
}
|
|
87
|
+
function cancelFull() { const t = full.title; full.on = false; $('fullToggle').setAttribute('aria-pressed', 'false'); resetFull(); $('thought').value = t; $('thought').dispatchEvent(new Event('input')); $('thought').focus(); }
|
|
88
|
+
function advance(v) { const FIELDS = fields(); full.answers[FIELDS[full.step].key] = v; full.step++; $('thought').value = ''; if (full.step < FIELDS.length) showStepPrompt(); else { fileIdea(full.title, { grade: 'full', fields: { ...full.answers } }); resetFull(); } $('thought').focus(); }
|
|
89
|
+
$('thought').addEventListener('keydown', (e) => { if (e.key === 'Escape' && full.step >= 0) { e.preventDefault(); cancelFull(); } });
|
|
90
|
+
function completeness(vals) { return fields().filter((f) => (vals[f.key] || '').trim().length >= f.floor).length; }
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------- filing
|
|
93
|
+
function receipt(html, go) { $('receiptText').innerHTML = html; $('receipt').hidden = false; $('receiptGo').hidden = !go; $('receiptGo').onclick = go || null; }
|
|
94
|
+
function explainFiling(r) {
|
|
95
|
+
const err = r && r.data && r.data.error; const code = err && (typeof err === 'object' ? err.code : err); const details = (err && typeof err === 'object' && err.details) || {};
|
|
96
|
+
if (details.reason) return `not filed — ${esc(details.reason)}`;
|
|
97
|
+
if (code) return `not filed — ${esc(String(code).replace(/_/g, ' '))}`;
|
|
98
|
+
if (r && (r.status === 401 || r.status === 403)) return 'not filed — your session may have expired. reload and try again';
|
|
99
|
+
return `not filed (${r ? r.status : 'no answer'}) — nothing was saved, your line is still here`;
|
|
100
|
+
}
|
|
101
|
+
async function fileIdea(title, opts) {
|
|
102
|
+
const isFull = opts.grade === 'full'; const FIELDS = fields();
|
|
103
|
+
if (isFull && !S.template) { receipt('not filed — the full idea template could not be read'); return; }
|
|
104
|
+
const body = isFull
|
|
105
|
+
? { title, grade: 'full', template_key: S.template.key, field_values: { ...opts.fields } }
|
|
106
|
+
: { title, body_md: '', kind: 'feature' };
|
|
107
|
+
const verdict = S.gravityVerdict;
|
|
108
|
+
if (!isFull && verdict && verdict.outcome === 'routed' && verdict.goal) body.goal_id = Number(verdict.goal.id);
|
|
109
|
+
$('fileGo').disabled = true;
|
|
110
|
+
const r = await api.request('POST', `${API}/inbox`, { body }).catch(() => null);
|
|
111
|
+
$('fileGo').disabled = false;
|
|
112
|
+
if (!r || !r.ok || !r.data || !r.data.idea) { $('thought').value = title; $('file').classList.add('live'); receipt(explainFiling(r)); return; }
|
|
113
|
+
S.filedN++;
|
|
114
|
+
const row = r.data.idea; const routed = r.data.routed_task; const routing = row.routing;
|
|
115
|
+
const caughtId = routed && routing && routing.routed ? String(routed.goal_id ?? (verdict && verdict.goal && verdict.goal.id)) : null;
|
|
116
|
+
const g = caughtId ? (S.goalById.get(caughtId) || { id: caughtId, title: (verdict && verdict.goal && verdict.goal.title) || 'a goal' }) : null;
|
|
117
|
+
const awaiting = !!(routed && routed.status === 'backlog');
|
|
118
|
+
const idea = {
|
|
119
|
+
id: String(row.id), title: row.title || title, kind: row.kind || 'feature', status: row.status || 'open', capturedAt: row.captured_at || new Date().toISOString(), grade: row.grade || (isFull ? 'full' : 'quick'), fields: isFull ? { ...opts.fields } : null,
|
|
120
|
+
lineage: routed ? [{ id: String(routed.id), title: routed.title || title, status: routed.status || 'backlog', goalId: caughtId, origin: 'route' }] : [], descentTasks: [],
|
|
121
|
+
size: { credits: 0, payments: 0, scale: 0, historical: false },
|
|
122
|
+
state: { state: 'protostar', position: g ? 'well' : 'open-space', wellGoalId: g ? g.id : null, awaitingNod: awaiting, hasWork: false, attributable: true, brightness: 1 },
|
|
123
|
+
st: 'protostar', well: g ? g.id : null, lit: 1, justFiled: true, awaiting, age: 0, born: NOW, origin: 'human', filedVia: null, mergedInto: null,
|
|
124
|
+
};
|
|
125
|
+
S.baseIdeas.unshift(idea); SKY.applyRehearsal(); SKY.placeIdeas(); const placed = S.ideas.find((i) => i.id === idea.id);
|
|
126
|
+
S.flying = { idea: placed, t0: performance.now(), ms: SKY.reduce ? 1 : 1100 };
|
|
127
|
+
$('thought').value = ''; $('file').classList.remove('live'); S.gravityGoal = null; S.gravityVerdict = null; gravitySeq++; $('gravity').textContent = IDLE;
|
|
128
|
+
const done = isFull ? completeness(opts.fields) : 0; const landedFull = row.grade === 'full';
|
|
129
|
+
receipt((isFull ? (landedFull ? `filed as a full idea · <b>${done} of ${FIELDS.length}</b> answers above the floor · ` : 'filed, but stored as a quick idea — the grade could not be written · ') : 'filed · ') + (g ? `caught by <b>${esc(short(g.title))}</b> · a task in its well, ${awaiting ? 'waiting for a nod' : 'claimable straight away'}` : 'nothing caught it · floating in open space'), () => SKY.pick({ kind: 'idea', ref: placed }, true));
|
|
130
|
+
SKY.renderRoom(); SKY.updateStats(); S.dirty = true;
|
|
131
|
+
}
|
|
132
|
+
$('file').addEventListener('submit', (e) => {
|
|
133
|
+
e.preventDefault(); const v = $('thought').value.trim();
|
|
134
|
+
if (full.on && full.step >= 0) { advance(v); return; }
|
|
135
|
+
if (!v) return;
|
|
136
|
+
if (full.on) { full.title = v; full.step = 0; $('thought').value = ''; showStepPrompt(); S.dirty = true; return; }
|
|
137
|
+
fileIdea(v, { grade: 'quick' });
|
|
138
|
+
});
|
|
139
|
+
$('receiptX').addEventListener('click', () => { $('receipt').hidden = true; });
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- the nod
|
|
142
|
+
// The existing awaiting-nod promote path, on the task the routed filing
|
|
143
|
+
// became. The button is drawn only for a holder of idea.triage (sky-panel.js).
|
|
144
|
+
async function nod(i, btn) {
|
|
145
|
+
const t = (i.lineage || []).find((x) => x.origin === 'route' && x.status === 'backlog') || (i.lineage || [])[0];
|
|
146
|
+
if (!t) { btn.textContent = 'could not nod — no task to make claimable'; btn.disabled = true; return; }
|
|
147
|
+
btn.disabled = true;
|
|
148
|
+
const r = await api.request('POST', `${API}/tasks/${encodeURIComponent(t.id)}/promote`, { body: {} }).catch(() => null);
|
|
149
|
+
if (r && r.ok) { i.awaiting = false; i.nodded = true; if (i.state) i.state.awaitingNod = false; t.status = 'ready'; btn.textContent = 'nodded — a builder can claim it now'; SKY.renderRoom(); S.dirty = true; return; }
|
|
150
|
+
btn.disabled = false; const err = r && r.data && r.data.error; const code = err && (typeof err === 'object' ? err.code : err);
|
|
151
|
+
btn.textContent = `could not nod — ${code ? String(code).replace(/_/g, ' ') : (r ? r.status : 'no answer')}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Object.assign(SKY, { fields, full, completeness, fileIdea, refreshGravity, gravitySentence, nod, resetFull });
|
|
155
|
+
})();
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// modules/hall-ui/public/sky-data.js — THE SKY, part 1 of 7: helpers, the
|
|
2
|
+
// shared state, the vocabulary, the world and the thoughts (task 1004231 /
|
|
3
|
+
// sky part 3 of 5, goal 1000095; ADR 0331, ADR 0227 §11).
|
|
4
|
+
//
|
|
5
|
+
// THE PAGE IS docs/design/mocks/sky/sky.template.html, LANDED. The owner adopted
|
|
6
|
+
// that prototype as the design of record — copy, layout, colours, tour text,
|
|
7
|
+
// toggles, interactions — and this page is its pixel contract, built for real.
|
|
8
|
+
// The template's one ~700-line script is split into seven files under the
|
|
9
|
+
// 1,500-line ratchet (the README says a split is not a deviation; a changed
|
|
10
|
+
// behaviour inside one is). Every file reads and writes ONE shared object,
|
|
11
|
+
// `window.OTBSky`: `SKY.S` is the mutable state the prototype kept in closure
|
|
12
|
+
// `let`s, everything else on SKY is a function or a constant. Load order is
|
|
13
|
+
// data → layout → draw → interaction → panel → composer → tour, and a file only
|
|
14
|
+
// CALLS a later file's function through `SKY.name(...)`, never at load time.
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS FILE OWNS. The helpers the template opened with; the five-object
|
|
17
|
+
// vocabulary read off ./idea-objects.js (the shared table — never a copy);
|
|
18
|
+
// the adaptors from the two reads the page makes (GET /sky, GET /sky/mine —
|
|
19
|
+
// part 2) onto the field names the prototype's snapshot carried; the version
|
|
20
|
+
// BANDS, derived from GET /versions because the core cannot hardcode one
|
|
21
|
+
// instance's version ids; the goal layout; and the thoughts — their state,
|
|
22
|
+
// their placement, their words.
|
|
23
|
+
//
|
|
24
|
+
// TWO LAYERS ARE HIDDEN UNTIL PART 5 makes them ledger facts (the README's
|
|
25
|
+
// "must not ship" rows): the "claude layer" pill and every born-of mark. The
|
|
26
|
+
// code that draws them is kept, and it reads `filed_via` / `bornOf` that are
|
|
27
|
+
// simply absent today — so part 5 turns them on by shipping the facts.
|
|
28
|
+
|
|
29
|
+
(() => {
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const SKY = window.OTBSky = window.OTBSky || {};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- helpers
|
|
35
|
+
const $ = (id) => document.getElementById(id);
|
|
36
|
+
const TAU = Math.PI * 2;
|
|
37
|
+
const GOLDEN = 137.50776405003785 * Math.PI / 180;
|
|
38
|
+
const reduce = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
39
|
+
const NOW = Date.now();
|
|
40
|
+
function hash(s) { let h = 2166136261; s = String(s); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967296; }
|
|
41
|
+
function rng(seed) { let t = Math.floor(hash(seed) * 1e9) + 1; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; }
|
|
42
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
43
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
44
|
+
const smooth = (v, a, b) => { const t = clamp((v - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
|
|
45
|
+
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
|
46
|
+
// esc — the prototype builds HTML strings, and every title, criterion and
|
|
47
|
+
// goal name passes through here first, so a title never becomes markup.
|
|
48
|
+
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
|
49
|
+
function fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }).toLowerCase(); }
|
|
50
|
+
function fmtDay(t) { return new Date(t).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).toLowerCase(); }
|
|
51
|
+
function ageDays(iso) { return iso ? (NOW - new Date(iso).getTime()) / 86400000 : 0; }
|
|
52
|
+
function plain(md) { return String(md || '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`#>]/g, '').replace(/\s+/g, ' ').trim(); }
|
|
53
|
+
const short = (t) => String(t).split(' — ')[0];
|
|
54
|
+
const store = { get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* fine */ } } };
|
|
55
|
+
const STOP = new Set('a an the to of in on for and or is it this that with from by as at be are was not no we you your our into onto when what how why should could would can will every each all any some one two page let me without does did has have had its their them then than but so if'.split(' '));
|
|
56
|
+
function tokens(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)); }
|
|
57
|
+
const toMs = (iso) => iso ? new Date(iso).getTime() : 0;
|
|
58
|
+
// The hall's own doors — the prototype linked to a fixed hall origin; the
|
|
59
|
+
// page links to the hall it is served from (apex /builders or the subdomain).
|
|
60
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
61
|
+
const API = '/api/bongos';
|
|
62
|
+
// baseUrl:'' — full paths through api.request, the way the room and the work
|
|
63
|
+
// page do it (thinking.js's rule: never mix the two client idioms on one page).
|
|
64
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------- the five objects
|
|
67
|
+
// The word, the noun and the sentence come from the ONE shared table
|
|
68
|
+
// (idea-objects.js, ADR 0322's other half); the server decides WHICH object.
|
|
69
|
+
const objects = window.OTBIdeaObjects;
|
|
70
|
+
const OBJ = {};
|
|
71
|
+
for (const key of objects.ORDER) { const o = objects.objectFor(key); OBJ[key] = { word: o.label, noun: o.noun, blurb: o.blurb }; }
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- shared state
|
|
74
|
+
// Every `let` the prototype's closure held, on one object so seven files can
|
|
75
|
+
// read and write the same page. Data-derived fields are filled by buildWorld.
|
|
76
|
+
const S = {
|
|
77
|
+
goals: [], goalById: new Map(), taskById: new Map(), edges: [], BANDS: [], ASPECT: 1, BORN: { goals: {}, criteria: {} },
|
|
78
|
+
baseIdeas: [], ideas: [], becameGoal: new Map(), becameCrit: new Map(), T0: NOW - 86400000,
|
|
79
|
+
hasFiledVia: false, canNod: false, template: null, buildingVersionIds: [], loaded: false,
|
|
80
|
+
rehearsal: false, claudeLayer: false, influence: false, ideasOnMap: false, recentOnly: false,
|
|
81
|
+
lens: 'mine', picked: null, hover: null, filter: 'all', gfilter: 'all', dirty: true, gravityGoal: null, flying: null, tourSpot: null,
|
|
82
|
+
clock: NOW, playing: false, clockHidden: store.get('sky-clock') === 'off',
|
|
83
|
+
charted: new Map(), focusSet: null, explore: { on: store.get('sky-explore') !== 'off', seen: new Set(JSON.parse(store.get('sky-seen') || '[]')) },
|
|
84
|
+
cam: { x: 0, y: 0, k: 0.12 }, flight: null, W: 0, H: 0, DPR: 1, t0: 0, shuttles: [],
|
|
85
|
+
drag: null, menuEdge: null, menuTimer: null, filedN: 0, tourI: -1, spotted: [], exploreWas: null, lastTick: 0,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------- the reads → the snapshot's names
|
|
89
|
+
// The prototype read a snapshot (docs/design/mocks/sky/snapshot.js). Each
|
|
90
|
+
// adaptor maps one real read onto the same field names, so the world below
|
|
91
|
+
// is the prototype's world line for line.
|
|
92
|
+
function goalFromSky(g) {
|
|
93
|
+
return {
|
|
94
|
+
id: String(g.goal_id), title: g.title || 'A private goal', subtitle: g.subtitle || null, version: g.version_id, status: g.status,
|
|
95
|
+
taskTotal: Number(g.task_total) || 0, taskDone: Number(g.task_done) || 0, createdAt: g.created_at || null,
|
|
96
|
+
crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true })),
|
|
97
|
+
tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const taskRow = (t) => ({ id: String(t.task_id ?? t.id), title: t.title || 'a task', status: t.status, goalId: t.goal_id != null ? String(t.goal_id) : null, origin: t.origin || null });
|
|
101
|
+
function ideaFromMine(i) {
|
|
102
|
+
const st = i.state || null;
|
|
103
|
+
return {
|
|
104
|
+
id: String(i.id), title: i.title || '', kind: i.kind || null, status: i.status, capturedAt: i.captured_at || null, grade: i.grade || 'quick',
|
|
105
|
+
fields: i.field_values && typeof i.field_values === 'object' ? i.field_values : null,
|
|
106
|
+
lineage: (i.lineage_tasks || []).map(taskRow), descentTasks: (i.descent_tasks || []).map(taskRow),
|
|
107
|
+
size: i.size || null,
|
|
108
|
+
state: st ? { ...st, wellGoalId: st.wellGoalId != null ? String(st.wellGoalId) : null } : null,
|
|
109
|
+
mergedInto: i.merged_into_idea_id != null ? String(i.merged_into_idea_id) : null,
|
|
110
|
+
// filed_via is part 5's fact. Until it exists every thought is a person's
|
|
111
|
+
// (the owner: historic ideas with no filed_via SHOW by default).
|
|
112
|
+
filedVia: i.filed_via ?? null, origin: i.filed_via === 'session' ? 'claude' : 'human',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------- the bands
|
|
117
|
+
// Versions are bands told apart by their ground. The prototype named this
|
|
118
|
+
// instance's versions by hand; the core cannot, so the bands are DERIVED:
|
|
119
|
+
// the versions still building sit at the centre (the present), shipped
|
|
120
|
+
// versions ring outward newest first, and small versions share a band so no
|
|
121
|
+
// ring is razor-thin — which is the grouping the prototype drew by eye.
|
|
122
|
+
function bandsFor(versions, goals) {
|
|
123
|
+
const goalsIn = (id) => goals.filter((g) => g.version === id).length;
|
|
124
|
+
const known = new Set();
|
|
125
|
+
const present = versions.filter((v) => v.status !== 'shipped' && v.status !== 'archived').map((v) => v.id);
|
|
126
|
+
const shipped = versions.filter((v) => v.status === 'shipped' || v.status === 'archived').sort((a, b) => toMs(b.shipped_at || b.started_at) - toMs(a.shipped_at || a.started_at)).map((v) => v.id);
|
|
127
|
+
for (const g of goals) if (!versions.some((v) => v.id === g.version)) known.add(g.version);
|
|
128
|
+
const bands = [{ versions: [...present, ...known], r0: 0, r1: 2000, label: present.map((v) => v.toLowerCase()).join(' · ') || 'the present', state: 'building', tone: .028 }];
|
|
129
|
+
let group = [], count = 0;
|
|
130
|
+
const close = () => { if (group.length) { bands.push({ versions: group, count }); group = []; count = 0; } };
|
|
131
|
+
for (const id of shipped) { group.push(id); count += goalsIn(id); if (count >= 6) close(); }
|
|
132
|
+
close();
|
|
133
|
+
let r = 2300;
|
|
134
|
+
for (let n = 1; n < bands.length; n++) {
|
|
135
|
+
const b = bands[n]; const width = clamp(400 + 380 * Math.sqrt(b.count), 600, 2500);
|
|
136
|
+
b.r0 = r; b.r1 = r + width; r = b.r1 + 300; b.label = b.versions.map((v) => v.toLowerCase()).join(' · ');
|
|
137
|
+
b.state = n === bands.length - 1 ? 'the beginning' : 'shipped'; b.tone = Math.max(0.006, 0.016 - (n - 1) * 0.004); delete b.count;
|
|
138
|
+
}
|
|
139
|
+
return bands;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------- the world
|
|
143
|
+
// Laid out on a circle, then stretched once to the screen's shape, so a desk
|
|
144
|
+
// gets a sky as wide as its glass.
|
|
145
|
+
function wellRadius(g) { const t = Math.log2(1 + g.mass / 400); const base = 70 + 60 * t; return g.status === 'open' ? base : g.status === 'achieved' ? base * 0.8 : base * 0.5; }
|
|
146
|
+
function ringR(g, i, n) { return g.r * (n === 1 ? 0.68 : 0.36 + (0.9 - 0.36) * i / (n - 1)); }
|
|
147
|
+
function layoutGoals(goals, BANDS, ASPECT) {
|
|
148
|
+
for (const band of BANDS) {
|
|
149
|
+
const members = goals.filter((g) => band.versions.includes(g.version)); members.sort((a, b) => b.mass - a.mass);
|
|
150
|
+
const n = members.length; band.count = n; band.tasks = members.reduce((a, g) => a + (g.taskTotal || 0), 0);
|
|
151
|
+
members.forEach((g, i) => { g.r = wellRadius(g); const a = i * GOLDEN + hash(g.id) * 0.5; const f = Math.sqrt((i + 0.5) / n); const rad = band.r0 === 0 ? f * band.r1 * 0.82 : band.r0 + (band.r1 - band.r0) * (0.15 + 0.7 * hash(g.id + 'r')); g.x = Math.cos(a) * rad; g.y = Math.sin(a) * rad; g.band = band; });
|
|
152
|
+
}
|
|
153
|
+
for (let it = 0; it < 360; it++) {
|
|
154
|
+
for (let i = 0; i < goals.length; i++) for (let j = i + 1; j < goals.length; j++) {
|
|
155
|
+
const a = goals[i], b = goals[j]; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1;
|
|
156
|
+
const min = a.r + b.r + (a.status === 'open' && b.status === 'open' ? 260 : 120);
|
|
157
|
+
if (d < min) { const push = (min - d) / 2; const ux = dx / d, uy = dy / d; a.x -= ux * push; a.y -= uy * push; b.x += ux * push; b.y += uy * push; }
|
|
158
|
+
}
|
|
159
|
+
for (const g of goals) { const d = Math.hypot(g.x, g.y) || 1; const lo = g.band.r0 + g.r + 30, hi = g.band.r1 - g.r - 30; if (g.band.r0 === 0) { if (d > hi) { g.x *= hi / d; g.y *= hi / d; } } else if (d < lo) { g.x *= lo / d; g.y *= lo / d; } else if (d > hi) { g.x *= hi / d; g.y *= hi / d; } }
|
|
160
|
+
}
|
|
161
|
+
for (const g of goals) g.x *= ASPECT; // the one stretch
|
|
162
|
+
}
|
|
163
|
+
function taskAt(t, tt) { const a = t.ang + (reduce ? 0 : tt * t.drift); return [Math.cos(a) * (t.orbit + t.jit), Math.sin(a) * (t.orbit + t.jit)]; }
|
|
164
|
+
|
|
165
|
+
// buildWorld — the two reads (plus the versions) into the prototype's world.
|
|
166
|
+
// Called once, after the reads; everything it derives lands on S.
|
|
167
|
+
function buildWorld({ sky, mine, versions }) {
|
|
168
|
+
S.ASPECT = clamp(innerWidth / Math.max(1, innerHeight), 1, 1.8);
|
|
169
|
+
const BORN = S.BORN;
|
|
170
|
+
const goals = ((sky && sky.goals) || []).map(goalFromSky).map((g) => ({ ...g, bornOf: BORN.goals[g.id] || null, born: toMs(g.createdAt) }));
|
|
171
|
+
const goalById = new Map(goals.map((g) => [g.id, g]));
|
|
172
|
+
const taskById = new Map(); for (const g of goals) for (const t of g.tasks) { t.goal = g; t.born = t.createdAt ? new Date(t.createdAt).getTime() : 0; taskById.set(t.id, t); }
|
|
173
|
+
for (const g of goals) { const oldest = Math.min(...g.tasks.map((t) => t.born || Infinity)); if (oldest < (g.born || Infinity)) g.born = oldest; }
|
|
174
|
+
for (const g of goals) {
|
|
175
|
+
const c = g.tasks.reduce((a, t) => a + (t.credits || 0), 0); g.credits = c; g.mass = c > 0 ? c : (g.taskTotal || 0) * 25;
|
|
176
|
+
g.active = g.tasks.filter((t) => t.status === 'active').length;
|
|
177
|
+
g.recent = g.tasks.filter((t) => NOW - t.born < 7 * 86400000).length;
|
|
178
|
+
// ALIVE is motion, not status: a builder holding a task now, or real work landing this week.
|
|
179
|
+
g.live = g.status === 'open' ? clamp(g.active * 0.6 + Math.max(0, g.recent - 3) / 30, 0, 1) : 0;
|
|
180
|
+
}
|
|
181
|
+
const BANDS = bandsFor(((versions && versions.versions) || []).map((v) => ({ id: String(v.id), status: v.status, shipped_at: v.shipped_at, started_at: v.started_at })), goals);
|
|
182
|
+
layoutGoals(goals, BANDS, S.ASPECT);
|
|
183
|
+
for (const g of goals) {
|
|
184
|
+
const n = Math.min(g.crit.length, 8);
|
|
185
|
+
for (let i = 0; i < g.crit.length; i++) { const c = g.crit[i]; c.credits = c.tasks.reduce((a, id) => a + ((taskById.get(id) || {}).credits || 0), 0); c.bornOf = BORN.criteria[c.id] || null; c.goal = g; c.index = i; }
|
|
186
|
+
for (const t of g.tasks) {
|
|
187
|
+
const ci = g.crit.findIndex((c) => c.tasks.includes(t.id));
|
|
188
|
+
t.orbit = ci >= 0 && ci < n ? ringR(g, ci, n) : g.r * 0.99;
|
|
189
|
+
const key = t.sourceRef ? 'idea:' + t.sourceRef : 'word:' + (tokens(t.title).sort((a, b) => b.length - a.length)[0] || t.id);
|
|
190
|
+
t.ang = hash(key) * TAU + (hash(t.id) - 0.5) * 0.09; t.jit = (hash(t.id + 'j') - 0.5) * 8; t.ci = ci; t.kin = key;
|
|
191
|
+
// work in motion moves: a held or claimable task drifts along its ring; landed work holds still
|
|
192
|
+
t.drift = (t.status === 'active' ? 0.03 : t.status === 'ready' ? 0.012 : 0) * (hash(t.id + 'd') > 0.5 ? 1 : -1);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const rows = ((mine && mine.ideas) || []).map(ideaFromMine);
|
|
196
|
+
S.hasFiledVia = rows.some((i) => i.filedVia != null);
|
|
197
|
+
S.baseIdeas = rows.map((i) => ({ ...i, st: (i.state && i.state.state) || 'protostar', well: (i.state && i.state.wellGoalId) || null, lit: (i.state && typeof i.state.brightness === 'number') ? i.state.brightness : 1, age: ageDays(i.capturedAt), born: toMs(i.capturedAt), origin: i.origin || 'human', descentTasks: i.descentTasks || [] }));
|
|
198
|
+
S.becameGoal = new Map(Object.entries(BORN.goals).map(([g, i]) => [i, g]));
|
|
199
|
+
S.becameCrit = new Map(); for (const g of goals) for (const c of g.crit) if (c.bornOf) S.becameCrit.set(c.bornOf, c);
|
|
200
|
+
S.goals = goals; S.goalById = goalById; S.taskById = taskById; S.BANDS = BANDS;
|
|
201
|
+
S.edges = ((sky && sky.edges) || []).map((e) => ({ from: String(e.from), to: String(e.to), count: Number(e.count) || 0 })).filter((e) => goalById.has(e.from) && goalById.has(e.to)).map((e, n) => ({ ...e, id: 'e' + n, a: goalById.get(e.from), b: goalById.get(e.to) }));
|
|
202
|
+
const t0 = Math.min(...goals.map((g) => g.born || Infinity), ...S.baseIdeas.map((i) => i.born || Infinity), ...[...taskById.values()].map((t) => t.born || Infinity));
|
|
203
|
+
S.T0 = Number.isFinite(t0) && t0 < NOW ? t0 : NOW - 86400000;
|
|
204
|
+
applyRehearsal(); placeIdeas();
|
|
205
|
+
S.loaded = true;
|
|
206
|
+
}
|
|
207
|
+
const edgesOf = (g) => S.edges.filter((e) => e.a === g || e.b === g);
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------- the thoughts
|
|
210
|
+
function applyRehearsal() {
|
|
211
|
+
if (!S.rehearsal) { S.ideas = S.baseIdeas; return; }
|
|
212
|
+
let k = 0;
|
|
213
|
+
S.ideas = S.baseIdeas.map((i) => { const c = { ...i }; if (i.st === 'star') { k++; if (k % 6 === 0) { c.st = 'remnant'; c.lit = 0; c.invented = true; } else if (k % 3 === 0) { c.lit = 0.35 + hash(i.id) * 0.45; c.invented = true; } } if (i.st === 'protostar' && i.well && hash(i.id + 'n') < 0.35) { c.awaiting = true; c.invented = true; } return c; });
|
|
214
|
+
}
|
|
215
|
+
function inLayer(i) { return !!(S.claudeLayer || i.origin !== 'claude' || i.fresh); }
|
|
216
|
+
function shown(i) { return inLayer(i) && (S.filter === 'all' || i.st === S.filter) && (S.lens !== 'all' || S.ideasOnMap) && i.born <= S.clock && (S.lens !== 'all' || !i.well || SKY.seen(S.goalById.get(i.well))) && (S.lens !== 'simple' || !S.recentOnly || i.age <= 30); }
|
|
217
|
+
// diameter — SIZE is the credits channel and only that: the server's `scale`
|
|
218
|
+
// (one compression curve, modules/ideas/trail-size.js), never a curve of its
|
|
219
|
+
// own, and never brightness — a remnant is as big as its work was.
|
|
220
|
+
function diameter(i) { const s = i.size && typeof i.size.scale === 'number' ? clamp(i.size.scale, 0, 1) : 0; return 6 + 22 * s; }
|
|
221
|
+
function positionWord(i) { if (i.st !== 'protostar') return null; return i.well ? (i.awaiting || (i.state && i.state.awaitingNod) ? 'waiting for a nod' : 'has a home') : 'nothing has claimed it'; }
|
|
222
|
+
// brightnessWord — THE THREE ANSWERS STAY THREE: a share, "unknown" when the
|
|
223
|
+
// removal facts could not be read (attributable:false is not zero), or
|
|
224
|
+
// nothing when there was never any work to be alive (idea-state.js step 3).
|
|
225
|
+
function brightnessWord(i) { if (!(i.state && i.state.hasWork) && i.st !== 'remnant' && !(i.lit < 1)) return null; if (i.state && i.state.attributable === false) return 'still alive — unknown'; const p = Math.round(i.lit * 100); return p >= 100 ? 'all of it still alive' : p <= 0 ? 'none of it still alive' : p + '% still alive'; }
|
|
226
|
+
// where a thought's descent reaches: every goal holding a task that fell out of it, beyond the well that caught it
|
|
227
|
+
function reach(i) { const s = new Set(); for (const t of [...i.lineage, ...i.descentTasks]) { const tk = S.taskById.get(t.id); const g = (tk && tk.goal) || (t.goalId && S.goalById.get(t.goalId)); if (g && g.id !== i.well) s.add(g); } return [...s]; }
|
|
228
|
+
function became(i) {
|
|
229
|
+
if (S.becameGoal.has(i.id)) return 'a goal — it shaped the space';
|
|
230
|
+
if (S.becameCrit.has(i.id)) return 'a criterion — a ring in ' + short(S.becameCrit.get(i.id).goal.title);
|
|
231
|
+
const n = i.lineage.length + i.descentTasks.length;
|
|
232
|
+
if (i.st === 'satellite') return 'folded into another thought';
|
|
233
|
+
if (i.st === 'cold-body') return 'declined at triage';
|
|
234
|
+
if (n === 0) return 'nothing yet';
|
|
235
|
+
const shipped = [...i.lineage, ...i.descentTasks].filter((t) => t.status === 'shipped').length; const r = reach(i).length;
|
|
236
|
+
return `${n} task${n === 1 ? '' : 's'}${shipped ? `, ${shipped} shipped` : ''}${r ? `, reaching ${r} other goal${r === 1 ? '' : 's'}` : ''}`;
|
|
237
|
+
}
|
|
238
|
+
function temp(i) { const t = clamp(i.age / 180, 0, 1); return [Math.round(lerp(255, 175, t)), Math.round(lerp(238, 196, t)), Math.round(lerp(222, 235, t))]; }
|
|
239
|
+
function placeIdeas() {
|
|
240
|
+
const { goals, goalById, ideas, becameGoal, becameCrit, BANDS, ASPECT } = S;
|
|
241
|
+
const byWell = new Map(); const open = []; const byId = new Map(ideas.map((i) => [i.id, i]));
|
|
242
|
+
for (const i of ideas) {
|
|
243
|
+
i.shaped = null;
|
|
244
|
+
if (becameGoal.has(i.id) && goalById.has(becameGoal.get(i.id))) { const g = goalById.get(becameGoal.get(i.id)); i.x = g.x; i.y = g.y; i.shaped = { kind: 'goal', goal: g }; i.well = i.well || g.id; continue; }
|
|
245
|
+
if (becameCrit.has(i.id)) { const c = becameCrit.get(i.id); const g = c.goal; const n = Math.min(g.crit.length, 8); const rr = c.index < n ? ringR(g, c.index, n) : g.r * 0.99; const a = hash(i.id) * TAU; i.x = g.x + Math.cos(a) * rr; i.y = g.y + Math.sin(a) * rr; i.shaped = { kind: 'ring', goal: g, crit: c }; i.well = i.well || g.id; continue; }
|
|
246
|
+
const host = i.mergedInto && byId.get(i.mergedInto); const w = (host || i).well; i.host = host || null;
|
|
247
|
+
if (w && goalById.has(w)) { if (!byWell.has(w)) byWell.set(w, []); byWell.get(w).push(i); } else open.push(i);
|
|
248
|
+
}
|
|
249
|
+
for (const [w, list] of byWell) {
|
|
250
|
+
const g = goalById.get(w); const anchored = list.filter((i) => !i.host || !list.includes(i.host));
|
|
251
|
+
anchored.forEach((i, k) => { const a = k * GOLDEN + hash(i.id) * 0.4; const f = anchored.length <= 1 ? 0.5 : k / (anchored.length - 1); const rr = g.r * (0.16 + 0.66 * f); i.x = g.x + Math.cos(a) * rr; i.y = g.y + Math.sin(a) * rr; });
|
|
252
|
+
for (const i of list) if (i.host && list.includes(i.host)) { const a = hash(i.id) * TAU; i.x = i.host.x + Math.cos(a) * 14; i.y = i.host.y + Math.sin(a) * 14; }
|
|
253
|
+
}
|
|
254
|
+
const core = BANDS[0];
|
|
255
|
+
for (const i of open) {
|
|
256
|
+
const r = rng(i.id + 'open'); let ok = false; const target = core.r1 * (0.22 + 0.74 * clamp(i.age / 180, 0, 1));
|
|
257
|
+
for (let t = 0; t < 48 && !ok; t++) { const a = r() * TAU, rad = target + (r() - 0.5) * core.r1 * 0.14; const x = Math.cos(a) * rad * ASPECT, y = Math.sin(a) * rad; ok = true; for (const g of goals) if (g.band === core && Math.hypot(g.x - x, g.y - y) < g.r + 70) { ok = false; break; } if (ok) { i.x = x; i.y = y; } }
|
|
258
|
+
if (!ok) { i.x = Math.cos(hash(i.id) * TAU) * target * ASPECT; i.y = Math.sin(hash(i.id) * TAU) * target; }
|
|
259
|
+
if (i.host && i.host.x != null) { const a = hash(i.id) * TAU; i.x = i.host.x + Math.cos(a) * 14; i.y = i.host.y + Math.sin(a) * 14; }
|
|
260
|
+
}
|
|
261
|
+
for (const g of goals) g.thoughts = ideas.filter((i) => i.well === g.id);
|
|
262
|
+
}
|
|
263
|
+
function touched(g) { return g.thoughts.some(inLayer) || (g.bornOf && inLayer(S.baseIdeas.find((i) => i.id === g.bornOf) || {})) || g.crit.some((c) => c.bornOf); }
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------- the reads
|
|
266
|
+
// load — the whole sky in five requests, all at once: the wells (GET /sky),
|
|
267
|
+
// the caller's trail (GET /sky/mine), the versions (the bands), /me (who may
|
|
268
|
+
// nod), and the Full Idea template (the composer's five questions). A read
|
|
269
|
+
// that fails leaves its part empty and the page still draws — an empty sky
|
|
270
|
+
// is a state, not an error.
|
|
271
|
+
async function load() {
|
|
272
|
+
const get = (p) => api.request('GET', `${API}${p}`).then((r) => (r && r.ok ? r.data : null)).catch(() => null);
|
|
273
|
+
const [sky, mine, versions, me, tpl] = await Promise.all([get('/sky'), get('/sky/mine'), get('/versions?limit=200'), get('/me'), get('/inbox/template')]);
|
|
274
|
+
S.canNod = !!(me && me.permissions && me.permissions['idea.triage'] === true);
|
|
275
|
+
S.template = tpl && tpl.template && Array.isArray(tpl.template.fields) ? tpl.template : null;
|
|
276
|
+
S.buildingVersionIds = ((versions && versions.versions) || []).filter((v) => v.status === 'building').map((v) => String(v.id));
|
|
277
|
+
S.truncated = !!(mine && mine.truncated);
|
|
278
|
+
buildWorld({ sky, mine, versions });
|
|
279
|
+
return { sky, mine, versions, me };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
Object.assign(SKY, {
|
|
283
|
+
S, $, TAU, GOLDEN, reduce, NOW, hash, rng, clamp, lerp, smooth, ease, esc, fmtDate, fmtDay, ageDays, plain, short, store, tokens, toMs, hallBase, API, api,
|
|
284
|
+
OBJ, objects, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
|
|
285
|
+
applyRehearsal, inLayer, shown, diameter, positionWord, brightnessWord, reach, became, temp, placeIdeas, touched, load,
|
|
286
|
+
});
|
|
287
|
+
})();
|