@bongos/core 1.24.29 → 1.24.30
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 +78 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +36 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +86 -89
- package/docs/copy-registry.json +137 -175
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +3 -2
- package/docs/page-readings.json +89 -107
- package/modules/government/db.js +29 -0
- package/modules/government/docket.js +61 -4
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/builders.js +3 -79
- package/modules/hall-ui/public/hall-render.js +6 -81
- package/modules/hall-ui/public/hall-widgets.js +4 -45
- package/modules/hall-ui/public/home-city-draw.js +275 -0
- package/modules/hall-ui/public/home-city.css +66 -0
- package/modules/hall-ui/public/home-city.js +223 -0
- package/modules/hall-ui/public/index.html +5 -6
- package/modules/hall-ui/public/index.states.json +73 -8
- package/modules/hall-ui/records/home-city.md +66 -0
- package/modules/lifecycle/home-city.js +340 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/home-city.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/module-api.js +1 -1
- package/tests/claim_from_task_and_home.mjs +8 -43
- package/tests/hall_board_world.mjs +10 -12
- package/tests/hall_home_ships_bounded.mjs +4 -3
- package/tests/home_bounded_reads.mjs +16 -31
- package/tests/home_city.mjs +385 -0
- package/tests/home_city_widget.mjs +120 -0
- package/tests/leaderboard_roles.mjs +3 -2
|
@@ -185,9 +185,6 @@
|
|
|
185
185
|
// (same pattern as lastNeedsState). These are un-landed confirmed tasks that
|
|
186
186
|
// GATE new claims and whose reward is pending until they land on ship.
|
|
187
187
|
let lastRebaseState = null;
|
|
188
|
-
// The public version progress, which the Home version tab draws (task 1004774).
|
|
189
|
-
// Null until it lands.
|
|
190
|
-
let lastProgressState = null;
|
|
191
188
|
|
|
192
189
|
// Cached /me payload from the most recent loadAll() call. Used by the
|
|
193
190
|
// welcome-chip reopen path (#408) so we can re-render the welcome panel
|
|
@@ -874,62 +871,6 @@
|
|
|
874
871
|
if (btn) window.location.hash = `#/idea/${encodeURIComponent(btn.dataset.triageIdea)}`;
|
|
875
872
|
});
|
|
876
873
|
|
|
877
|
-
// ---- Home "Next up" slice (task 2184) -------------------------------------
|
|
878
|
-
|
|
879
|
-
// Fill the top of the "Your work" card with the first five rows of the
|
|
880
|
-
// claimable queue (server order: priority, then calibrated estimate) so Home
|
|
881
|
-
// answers "what's my next action". Best-effort: a primer-gated 403, an auth
|
|
882
|
-
// hiccup, or an empty queue hides the slice — Home never shows an error for
|
|
883
|
-
// it. Re-runs with every loadAll (SSE nudges keep it current).
|
|
884
|
-
async function loadNextUp() {
|
|
885
|
-
const wrap = document.getElementById('home-next');
|
|
886
|
-
const list = document.getElementById('home-next-list');
|
|
887
|
-
const subhead = document.getElementById('home-recent-subh');
|
|
888
|
-
if (!wrap || !list) return;
|
|
889
|
-
try {
|
|
890
|
-
// ?limit=2 — this card is TWO lines. It used to download the entire claimable
|
|
891
|
-
// feed, every row with its full description, and `.slice(0, 2)` it: the same
|
|
892
|
-
// shape goal 1000072's criterion names, and the one goal-rollup.js was carved to
|
|
893
|
-
// fix on the Goals page (5,229,455 bytes to produce forty counters). The server
|
|
894
|
-
// already orders by priority then calibrated estimate, so the first two rows ARE
|
|
895
|
-
// the top two. `claimable_total` comes back so the foot can say how many are
|
|
896
|
-
// waiting instead of leaving two rows to imply there are only two.
|
|
897
|
-
// task 1004774: five rows, level with the five Recently shipped rows beside it.
|
|
898
|
-
const resp = await getJSON(`${API}/tasks/claimable?limit=5`, { softAuth: true });
|
|
899
|
-
// capped as well as asked for: a server that ignores ?limit must not grow the card.
|
|
900
|
-
const rows = resp && Array.isArray(resp.claimable) ? resp.claimable.slice(0, 5) : [];
|
|
901
|
-
if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
|
|
902
|
-
const total = Number(resp.claimable_total);
|
|
903
|
-
const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
|
|
904
|
-
list.innerHTML = rows.map((t) => {
|
|
905
|
-
const credits = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
|
|
906
|
-
const mins = t.est_minutes ? ` · ~${escapeHtml(String(t.est_minutes))}m` : '';
|
|
907
|
-
// task 1003459 — this row now claims directly (the extracted claim
|
|
908
|
-
// action, task 1003458) rather than only linking to the record and
|
|
909
|
-
// making the builder find the door there.
|
|
910
|
-
return `<li class="next-up__item">
|
|
911
|
-
<a class="next-up__title" href="/task/${encodeURIComponent(t.id)}">${escapeHtml(t.title || `task ${t.id}`)}</a>
|
|
912
|
-
<span class="next-up__meta">#${escapeHtml(String(t.id))}${mins} · ${fmtNum(credits)} ${escapeHtml(cur)} est.</span>
|
|
913
|
-
<button class="task__claim next-up__claim" type="button" data-task-id="${escapeHtml(String(t.id))}" aria-label="Claim task ${escapeHtml(String(t.id))}">Claim</button>
|
|
914
|
-
</li>`;
|
|
915
|
-
}).join('');
|
|
916
|
-
// Name the size of the queue these two came off. Silent when the count is
|
|
917
|
-
// missing or not bigger than what is shown — "See all 2" under two rows is
|
|
918
|
-
// noise, and a made-up number is worse than none.
|
|
919
|
-
const foot = document.getElementById('home-next-foot');
|
|
920
|
-
if (foot) {
|
|
921
|
-
foot.textContent = Number.isFinite(total) && total > rows.length
|
|
922
|
-
? `See all ${fmtNum(total)} ready work →`
|
|
923
|
-
: 'See all ready work →';
|
|
924
|
-
}
|
|
925
|
-
wrap.hidden = false;
|
|
926
|
-
if (subhead) subhead.hidden = false;
|
|
927
|
-
} catch (_) {
|
|
928
|
-
wrap.hidden = true;
|
|
929
|
-
if (subhead) subhead.hidden = true;
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
874
|
// ---- the Roll’s rows, for the Standing card -----------------------------
|
|
934
875
|
//
|
|
935
876
|
// task 1003441 moved the leaderboard TABLE to its own page (/roster,
|
|
@@ -1084,11 +1025,6 @@
|
|
|
1084
1025
|
// /me because it spans all builders' work), loaded fire-and-forget so a
|
|
1085
1026
|
// slow/failed feed can't hold up the rest of the hall.
|
|
1086
1027
|
loadAcclaimFeed();
|
|
1087
|
-
// task 2184: the Home "Next up" slice — top of the claimable queue, so the
|
|
1088
|
-
// page answers "what's my next action" without a trip to the Work board.
|
|
1089
|
-
// Fire-and-forget; hides itself when the feed is gated (primer 403) or empty.
|
|
1090
|
-
loadNextUp();
|
|
1091
|
-
|
|
1092
1028
|
// #408: build the sticky nav now that every section has settled its
|
|
1093
1029
|
// hidden state (welcome → karma → onboarding → security/roster).
|
|
1094
1030
|
// The renderNav call also wires the IntersectionObserver for active-
|
|
@@ -1108,21 +1044,15 @@
|
|
|
1108
1044
|
// down the page — log and show an inline placeholder.
|
|
1109
1045
|
// #726: the per-skill model picker (Voices) moved to /builders/settings,
|
|
1110
1046
|
// so the hall no longer fetches /me/skill-prefs here.
|
|
1111
|
-
const [achAll, ships, versionsResp, lb
|
|
1047
|
+
const [achAll, ships, versionsResp, lb] = await Promise.allSettled([
|
|
1112
1048
|
getJSON(`${API}/achievements`),
|
|
1113
1049
|
// task 1004854: Home's newest few + a count — never the whole history,
|
|
1114
1050
|
// which only the Accomplishments ledger fetches (hall-render.js loadShipLedger).
|
|
1115
1051
|
getJSON(`${API}/me/recent-ships?limit=${HOME_SHIPS_LIMIT}`),
|
|
1116
1052
|
getJSON(`${API}/versions`),
|
|
1117
1053
|
getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
|
|
1118
|
-
getJSON(`${API}/public/progress`), // the Home version tab (task 1004774)
|
|
1119
1054
|
]);
|
|
1120
1055
|
|
|
1121
|
-
if (progress.status === 'fulfilled') {
|
|
1122
|
-
lastProgressState = progress.value;
|
|
1123
|
-
renderGreeting();
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
1056
|
if (achAll.status === 'fulfilled') {
|
|
1127
1057
|
// Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
|
|
1128
1058
|
// for V2, so the slice is defensive.) Unlocked + locked-with-progress come
|
|
@@ -1227,11 +1157,6 @@
|
|
|
1227
1157
|
// task 1004833: the shared vote control reports each landed vote to the feed.
|
|
1228
1158
|
$('#acclaim-feed')?.addEventListener('otb-vote', onAcclaimVote);
|
|
1229
1159
|
|
|
1230
|
-
// task 1003459 — the Claim button on each Next-up row; the action + the
|
|
1231
|
-
// delegated listener live in hall-render.js (builders.js is at the
|
|
1232
|
-
// 1,500-line ratchet, hall-ui/CLAUDE.md's "growth goes in the lib/renderer").
|
|
1233
|
-
wireNextUpClaim();
|
|
1234
|
-
|
|
1235
1160
|
$('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
|
|
1236
1161
|
$('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
|
|
1237
1162
|
$('#f-ships-sort')?.addEventListener('change', renderRecentShips);
|
|
@@ -1346,14 +1271,13 @@
|
|
|
1346
1271
|
// reads what this page holds NOW rather than what it held at boot. Everything
|
|
1347
1272
|
// else is stable for the life of the page. This call sits AFTER every
|
|
1348
1273
|
// declaration it passes — moving it earlier is a temporal-dead-zone crash.
|
|
1349
|
-
const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager, welcomeDismissalKey
|
|
1350
|
-
API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON,
|
|
1274
|
+
const { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, populateShipVersionFilter, setRecentShips, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
|
|
1275
|
+
API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
|
|
1351
1276
|
get instanceRepo() { return instanceRepo; },
|
|
1352
1277
|
get lastCostState() { return lastCostState; },
|
|
1353
1278
|
get lastKarmaState() { return lastKarmaState; },
|
|
1354
1279
|
get lastMeState() { return lastMeState; },
|
|
1355
1280
|
get lastNeedsState() { return lastNeedsState; },
|
|
1356
|
-
get lastProgressState() { return lastProgressState; },
|
|
1357
1281
|
get lastRebaseState() { return lastRebaseState; },
|
|
1358
1282
|
get newcomerGated() { return newcomerGated; },
|
|
1359
1283
|
});
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
'use strict';
|
|
23
23
|
|
|
24
24
|
window.OTBHallRender = function createHallRender(ctx) {
|
|
25
|
-
const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON,
|
|
25
|
+
const { API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, getJSON, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd } = ctx;
|
|
26
26
|
const { escapeHtml, $, fmtNum, moduleOn } = window.OTB;
|
|
27
27
|
|
|
28
28
|
const RANK_UNLOCKS = {
|
|
@@ -603,14 +603,6 @@
|
|
|
603
603
|
const el = document.getElementById(id);
|
|
604
604
|
if (el) el.textContent = text;
|
|
605
605
|
};
|
|
606
|
-
setText('stat-credits', fmtNum(builder.total_credits));
|
|
607
|
-
setText('stat-karma', fmtNum(karmaTotal));
|
|
608
|
-
setText('stat-karma-sub', karmaTrendText);
|
|
609
|
-
const statRank = document.getElementById('stat-rank');
|
|
610
|
-
if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
|
|
611
|
-
setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
|
|
612
|
-
const statsSection = document.getElementById('home-stats');
|
|
613
|
-
if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
|
|
614
606
|
|
|
615
607
|
// task 1003464: the Home card as the mock draws it — an identity LINE and a
|
|
616
608
|
// three-row fact ledger. The mock repeats Credits and the place even though
|
|
@@ -635,7 +627,7 @@
|
|
|
635
627
|
<dl class="home-facts home-facts--grid">
|
|
636
628
|
<div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
|
|
637
629
|
<div><dt>Karma</dt><dd>${fmtNum(karmaTotal)}</dd></div>
|
|
638
|
-
<div><dt>Shipped</dt><dd id="standing-shipped">${
|
|
630
|
+
<div><dt>Shipped</dt><dd id="standing-shipped">${shipState.seen ? fmtNum(shipState.total) : '—'}</dd></div>
|
|
639
631
|
<div><dt>Leaderboard place</dt><dd>${place}</dd></div>
|
|
640
632
|
</dl>
|
|
641
633
|
<dl class="home-facts">
|
|
@@ -716,39 +708,15 @@
|
|
|
716
708
|
}
|
|
717
709
|
|
|
718
710
|
// task 1003464: the greeting IS Home's one h1 (the mock opens with it).
|
|
719
|
-
// task 1004780: it has no sub-line any more
|
|
720
|
-
//
|
|
721
|
-
// repeating both was redundant (owner, 2026-10-23).
|
|
711
|
+
// task 1004780: it has no sub-line any more (owner, 2026-10-23); since task
|
|
712
|
+
// 1005054 the city under it (home-city.js) is how far the project has come.
|
|
722
713
|
const greetingWord = (hour) => (hour < 12 ? 'Good morning' : (hour < 18 ? 'Good afternoon' : 'Good evening'));
|
|
723
|
-
// The version the tab is about: the one being built with the most work in it.
|
|
724
|
-
function currentVersion(progress) {
|
|
725
|
-
const rows = progress && Array.isArray(progress.progress) ? progress.progress : [];
|
|
726
|
-
return rows.filter((r) => r && r.status === 'building')
|
|
727
|
-
.sort((a, b) => (Number(b.total_count) || 0) - (Number(a.total_count) || 0))[0] || null;
|
|
728
|
-
}
|
|
729
|
-
// task 1004774: the version tab under the Home banner, drawn as a bar.
|
|
730
|
-
// Hidden while no version is being built.
|
|
731
|
-
function renderVersionTab(v) {
|
|
732
|
-
const tab = document.getElementById('home-version');
|
|
733
|
-
if (!tab) return;
|
|
734
|
-
if (!v) { tab.hidden = true; return; }
|
|
735
|
-
const pct = Math.max(0, Math.min(100, Math.round(Number(v.percent_complete) || 0)));
|
|
736
|
-
const name = document.getElementById('home-version-name');
|
|
737
|
-
const fill = document.getElementById('home-version-fill');
|
|
738
|
-
const pctEl = document.getElementById('home-version-pct');
|
|
739
|
-
if (name) name.textContent = v.version_id;
|
|
740
|
-
if (fill) fill.style.width = `${pct}%`;
|
|
741
|
-
if (pctEl) pctEl.textContent = `${pct}% complete`;
|
|
742
|
-
tab.setAttribute('aria-label', `${v.version_id} is ${pct} percent complete`);
|
|
743
|
-
tab.hidden = false;
|
|
744
|
-
}
|
|
745
714
|
function renderGreeting() {
|
|
746
715
|
const h1 = document.getElementById('hall-h1');
|
|
747
716
|
const builder = ctx.lastMeState && ctx.lastMeState.builder;
|
|
748
717
|
if (h1 && builder) {
|
|
749
718
|
h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
|
|
750
719
|
}
|
|
751
|
-
renderVersionTab(currentVersion(ctx.lastProgressState));
|
|
752
720
|
}
|
|
753
721
|
const ONBOARDING_STAGE_ORDER = [
|
|
754
722
|
'auth_complete',
|
|
@@ -1096,28 +1064,9 @@
|
|
|
1096
1064
|
const countEl = $('#ships-count');
|
|
1097
1065
|
const pagerEl = $('#ships-pager');
|
|
1098
1066
|
|
|
1099
|
-
// task
|
|
1100
|
-
// 5-row recent-work table are fed from the same feed. Guarded writes.
|
|
1101
|
-
const statShipped = document.getElementById('stat-shipped');
|
|
1102
|
-
if (statShipped) statShipped.textContent = fmtNum(shipState.total);
|
|
1103
|
-
// task 1004774: the Standing hover card carries the same count.
|
|
1067
|
+
// task 1004774: the Standing hover card carries the shipped count.
|
|
1104
1068
|
const standingShipped = document.getElementById('standing-shipped');
|
|
1105
1069
|
if (standingShipped) standingShipped.textContent = fmtNum(shipState.total);
|
|
1106
|
-
const homeRecent = document.querySelector('#home-recent-table tbody');
|
|
1107
|
-
if (homeRecent) {
|
|
1108
|
-
const latest = sortShips(shipState.recent, 'recent').slice(0, HOME_SHIPS_LIMIT);
|
|
1109
|
-
homeRecent.innerHTML = latest.length
|
|
1110
|
-
? latest.map((s) => `
|
|
1111
|
-
<tr>
|
|
1112
|
-
<td class="ledger__num">#${s.id}</td>
|
|
1113
|
-
<td>${escapeHtml(s.title)}</td>
|
|
1114
|
-
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
1115
|
-
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
1116
|
-
</tr>`).join('')
|
|
1117
|
-
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
1118
|
-
}
|
|
1119
|
-
const homeRecentSection = document.getElementById('home-recent');
|
|
1120
|
-
if (homeRecentSection && !ctx.newcomerGated) homeRecentSection.hidden = false;
|
|
1121
1070
|
|
|
1122
1071
|
// The ledger waits for its own fetch (loadShipLedger); until then it keeps its placeholder.
|
|
1123
1072
|
const all = shipState.all;
|
|
@@ -1378,31 +1327,7 @@
|
|
|
1378
1327
|
return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
|
|
1379
1328
|
}
|
|
1380
1329
|
|
|
1381
|
-
// task 1003459 — the Claim button on each Home "Next up" row, through the
|
|
1382
|
-
// hall's ONE claim action (task 1003458) rather than a third hand-rolled
|
|
1383
|
-
// POST /claims. Lives here, not builders.js, because that file is at the
|
|
1384
|
-
// 1,500-line ratchet (hall-ui/CLAUDE.md: "growth in the page half has to be
|
|
1385
|
-
// paid for; new pure helper → the lib/renderer"). refresh re-fetches the
|
|
1386
|
-
// two-row slice so a claimed task drops out of it immediately.
|
|
1387
|
-
function wireNextUpClaim() {
|
|
1388
|
-
const claimAction = window.OTBClaimAction.makeClaimAction({ postJSON, toast, API, refresh: loadNextUp });
|
|
1389
|
-
$('#home-next-list')?.addEventListener('click', (e) => {
|
|
1390
|
-
const btn = e.target.closest('.next-up__claim');
|
|
1391
|
-
if (!btn || btn.disabled) return;
|
|
1392
|
-
const taskId = btn.dataset.taskId;
|
|
1393
|
-
btn.disabled = true;
|
|
1394
|
-
btn.textContent = '…';
|
|
1395
|
-
claimAction.claim(taskId).then((out) => {
|
|
1396
|
-
// A redirect to sign-in is already navigating away; re-enabling here
|
|
1397
|
-
// would just invite a click that can't land.
|
|
1398
|
-
if (!out.ok && !out.redirected) {
|
|
1399
|
-
btn.disabled = false;
|
|
1400
|
-
btn.textContent = 'Claim';
|
|
1401
|
-
}
|
|
1402
|
-
});
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
1330
|
|
|
1406
|
-
return { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, loadShipLedger, populateShipVersionFilter, setRecentShips, shipState, shipsPager, welcomeDismissalKey
|
|
1331
|
+
return { acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, renderCurveBody, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, HOME_SHIPS_LIMIT, loadShipLedger, populateShipVersionFilter, setRecentShips, shipState, shipsPager, welcomeDismissalKey };
|
|
1407
1332
|
};
|
|
1408
1333
|
})();
|
|
@@ -62,29 +62,8 @@
|
|
|
62
62
|
'<p class="ledger__note" id="onboarding-status" hidden></p>',
|
|
63
63
|
{ view: 'home', zone: 'home-banner', labelledby: 'onboarding-h', hiddenByDefault: true });
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// shipped count once the ships feed settles (renderRecentShips).
|
|
68
|
-
// task 2169: each tile is a LINK — the stat row doubles as the fastest
|
|
69
|
-
// navigation on the page (Credits → the economy doc, Shipped/Karma → the
|
|
70
|
-
// Standing sections that explain them, Rank → the ladder).
|
|
71
|
-
w('home-stats', 'Overview', 15,
|
|
72
|
-
'<h2 id="home-stats-h" class="sr-only">Overview</h2>' +
|
|
73
|
-
'<div class="stat-row" id="home-stats-row">' +
|
|
74
|
-
'<a class="stat-tile" href="/drachmae"><span class="stat-tile__label" data-currency-label="caps">Credits</span><span class="stat-tile__num" id="stat-credits">—</span></a>' +
|
|
75
|
-
'<a class="stat-tile" href="#accomplishments-work"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></a>' +
|
|
76
|
-
'<a class="stat-tile" href="#accomplishments-karma"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></a>' +
|
|
77
|
-
'<a class="stat-tile" href="/ranks"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></a>' +
|
|
78
|
-
'</div>' +
|
|
79
|
-
// task 1004774: the version tab under the banner — how far the version being
|
|
80
|
-
// built has come. hall-render.js renderGreeting fills it from the same
|
|
81
|
-
// progress read as the greeting's sub-line; hidden until there is a version.
|
|
82
|
-
'<a class="version-tab" id="home-version" href="/roadmap" hidden>' +
|
|
83
|
-
'<span class="version-tab__name" id="home-version-name"></span>' +
|
|
84
|
-
'<span class="version-tab__bar" aria-hidden="true"><span class="version-tab__fill" id="home-version-fill"></span></span>' +
|
|
85
|
-
'<span class="version-tab__pct" id="home-version-pct"></span>' +
|
|
86
|
-
'</a>',
|
|
87
|
-
{ view: 'home', zone: 'home-stats', sectionClass: 'scroll scroll--bare', labelledby: 'home-stats-h', hiddenByDefault: true });
|
|
65
|
+
// (task 1005054: the quick-stats strip and the version tab left Home. The
|
|
66
|
+
// zone they held, home-stats, is the project city's — home-city.js.)
|
|
88
67
|
|
|
89
68
|
// (task 1004774: the Standing summary is no longer a Home card. It is the
|
|
90
69
|
// chip at the top right of the page head — index.html #hall-standing — whose
|
|
@@ -97,28 +76,8 @@
|
|
|
97
76
|
// goal-inbox.js owns (#attention-ideas), the single landing place for every
|
|
98
77
|
// actionable item. builders.js still gates the /inbox fetch to Metic+.)
|
|
99
78
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// claimable queue (builders.js loadNextUp fills it; hidden when the feed is
|
|
103
|
-
// gated or empty), then the 5-row recent-ships table below it.
|
|
104
|
-
w('home-recent', 'Your work', 28,
|
|
105
|
-
'<h2 id="home-recent-h" class="scroll__h">Your work</h2>' +
|
|
106
|
-
// task 1004774: Next up and Recently shipped sit side by side (stacking
|
|
107
|
-
// again on a phone); when Next up is hidden, shipped takes the full width.
|
|
108
|
-
'<div class="home-split">' +
|
|
109
|
-
'<div class="home-split__col" id="home-next" hidden>' +
|
|
110
|
-
'<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
|
|
111
|
-
'<ul class="next-up" id="home-next-list"></ul>' +
|
|
112
|
-
'<p class="next-up__foot"><a href="/work" id="home-next-foot">See all ready work →</a></p>' +
|
|
113
|
-
'</div>' +
|
|
114
|
-
'<div class="home-split__col">' +
|
|
115
|
-
'<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
|
|
116
|
-
'<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
|
|
117
|
-
'<tbody><tr><td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…</span></td></tr></tbody></table>' +
|
|
118
|
-
'<p class="card-foot"><a href="#accomplishments-work">All your work →</a></p>' +
|
|
119
|
-
'</div>' +
|
|
120
|
-
'</div>',
|
|
121
|
-
{ view: 'home', zone: 'home-main', labelledby: 'home-recent-h', hiddenByDefault: true });
|
|
79
|
+
// (task 1005054: Your work — Next up and Recently shipped — left Home; ready
|
|
80
|
+
// work is the Work board's and your history is Accomplishments'.)
|
|
122
81
|
|
|
123
82
|
// 0f. The founding wall (task 1004424) — everyone who joined while the project was
|
|
124
83
|
// being founded, founder first, each with their mark. Hidden until the founding
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS IS. The drawing half of Home's city (the owner's v14 mock,
|
|
4
|
+
// docs/design/mocks/hall/home/v14.html): one isometric tower per working area,
|
|
5
|
+
// as tall as what has shipped there; artist work clads it in glass; the sites
|
|
6
|
+
// worked in a week carry a crane and the face of who built there; that week's
|
|
7
|
+
// ideas are lights in the sky; a salmon dot marks where something waits on the
|
|
8
|
+
// viewer. Beside it, the ladder: each builder's week as one line split by craft.
|
|
9
|
+
// home-city.js owns the page half (the read, the slider, the ladder's DOM,
|
|
10
|
+
// hover); this file only turns the weekly summary into shapes and rows.
|
|
11
|
+
//
|
|
12
|
+
// PURE. No DOM, no fetch, no clock: the same summary and week give the same
|
|
13
|
+
// shapes, so tests run it in node (the roadmap-timeline.js precedent). The scene
|
|
14
|
+
// comes back as a tree of plain shape objects, never markup (see citySvg).
|
|
15
|
+
//
|
|
16
|
+
// THE PALETTE is the city's own drawing data (city-draw.js isoTheme, trimmed to
|
|
17
|
+
// what this scene spends: the dark blocks in the black, the white paper model in
|
|
18
|
+
// light). Everything around the drawing takes the hall's tokens; salmon
|
|
19
|
+
// (var(--accent)) is spent only on what waits on the viewer.
|
|
20
|
+
(function (root) {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const FOOT = 1.8; // a tower's footprint, in tiles
|
|
24
|
+
const PER_ROW = 5; // towers per row; rows run back (tallest) to front
|
|
25
|
+
const ROW_GAP = 4.2; // tiles between rows
|
|
26
|
+
const COL_GAP = 3.0; // tiles between towers in a row
|
|
27
|
+
const MAX_AREAS = 15; // three rows; the rest are reported, not drawn
|
|
28
|
+
const TOWER_PX = 170; // the tallest tower at the latest week
|
|
29
|
+
const TW = 56, TH = 28; // iso tile, in viewBox units
|
|
30
|
+
const LADDER_ROWS = 7;
|
|
31
|
+
|
|
32
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
33
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
34
|
+
const sum = (a) => (Array.isArray(a) ? a.reduce((s, v) => s + num(v), 0) : 0);
|
|
35
|
+
|
|
36
|
+
function theme(mode) {
|
|
37
|
+
if (mode === 'light') {
|
|
38
|
+
return {
|
|
39
|
+
work: { t: '#f6f5f8', l: '#e6e5e9', r: '#d2d1d6', s: 'rgba(23,24,26,0.22)' },
|
|
40
|
+
plinth: { t: '#efeef1', l: '#e2e1e5', r: '#cfced3', s: 'rgba(23,24,26,0.22)' },
|
|
41
|
+
glassL: 'rgba(91,111,208,0.30)', glassR: 'rgba(91,111,208,0.42)', glassT: 'rgba(91,111,208,0.22)',
|
|
42
|
+
floor: 'rgba(23,24,26,0.10)', steel: 'rgba(23,24,26,0.62)', scaffold: 'rgba(23,24,26,0.40)', lit: 'rgba(226,150,90,0.55)',
|
|
43
|
+
slab: { t: '#d9d8dd', l: '#c4c3c9', r: '#b2b1b8', s: 'rgba(23,24,26,0.4)' },
|
|
44
|
+
ground: 'rgba(23,24,26,0.035)', groundLine: 'rgba(23,24,26,0.08)',
|
|
45
|
+
orb: '#d98a4e', orbGlow: 'rgba(217,138,78,0.30)', ideaLine: 'rgba(217,138,78,0.55)',
|
|
46
|
+
av: '#e4e3e7', avAgent: '#d3d8f2', avInk: '#17181a',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
work: { t: '#34343a', l: '#1c1c20', r: '#111114', s: 'rgba(255,255,255,0.12)' },
|
|
51
|
+
plinth: { t: '#27272c', l: '#141417', r: '#0b0b0d', s: 'rgba(255,255,255,0.08)' },
|
|
52
|
+
glassL: 'rgba(110,130,230,0.30)', glassR: 'rgba(110,130,230,0.20)', glassT: 'rgba(150,170,255,0.30)',
|
|
53
|
+
floor: 'rgba(255,255,255,0.06)', steel: 'rgba(255,255,255,0.70)', scaffold: 'rgba(255,255,255,0.40)', lit: 'rgba(255,200,150,0.55)',
|
|
54
|
+
slab: { t: '#5a5a62', l: '#3a3a40', r: '#2a2a2f', s: 'rgba(255,255,255,0.2)' },
|
|
55
|
+
ground: 'rgba(255,255,255,0.018)', groundLine: 'rgba(255,255,255,0.05)',
|
|
56
|
+
orb: '#ffe2c2', orbGlow: 'rgba(255,214,170,0.35)', ideaLine: 'rgba(255,214,170,0.45)',
|
|
57
|
+
av: '#2c2c32', avAgent: '#2b3157', avInk: '#ffffff',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The summary as the drawing needs it: areas placed on the ground (tallest at
|
|
62
|
+
// the back), one shared height scale, people by id. A summary the server could
|
|
63
|
+
// not fill (no version being built, no areas) gives a model with no areas and
|
|
64
|
+
// the page renders nothing rather than an empty plate.
|
|
65
|
+
function cityModel(data) {
|
|
66
|
+
const d = data || {};
|
|
67
|
+
const weeks = Array.isArray(d.weeks) ? d.weeks : [];
|
|
68
|
+
const W = weeks.length;
|
|
69
|
+
const all = (Array.isArray(d.areas) ? d.areas : []).map((a) => {
|
|
70
|
+
const build = Array.from({ length: W }, (_, i) => num(a.build && a.build[i]));
|
|
71
|
+
const art = Array.from({ length: W }, (_, i) => num(a.art && a.art[i]));
|
|
72
|
+
const lead = Array.from({ length: W }, (_, i) => (a.lead && a.lead[i] != null ? String(a.lead[i]) : null));
|
|
73
|
+
return { id: String(a.goal_id), title: a.title == null ? 'A private area' : String(a.title), baseBuild: num(a.base_build), baseArt: num(a.base_art), build, art, lead };
|
|
74
|
+
});
|
|
75
|
+
all.forEach((a) => { a.total = a.baseBuild + a.baseArt + sum(a.build) + sum(a.art); });
|
|
76
|
+
all.sort((p, q) => q.total - p.total || p.id.localeCompare(q.id));
|
|
77
|
+
const areas = all.slice(0, MAX_AREAS);
|
|
78
|
+
const rows = Math.max(1, Math.ceil(areas.length / PER_ROW));
|
|
79
|
+
areas.forEach((a, n) => {
|
|
80
|
+
const row = Math.floor(n / PER_ROW), col = n % PER_ROW;
|
|
81
|
+
a.x = 0.8 + col * COL_GAP + (row % 2 ? 1.5 : 0);
|
|
82
|
+
a.y = 0.8 + row * ROW_GAP;
|
|
83
|
+
});
|
|
84
|
+
const tallest = Math.max(1, ...areas.map((a) => a.total));
|
|
85
|
+
const people = {};
|
|
86
|
+
(Array.isArray(d.people) ? d.people : []).forEach((p) => {
|
|
87
|
+
const row = (k) => Array.from({ length: W }, (_, i) => num(p[k] && p[k][i]));
|
|
88
|
+
people[String(p.id)] = { id: String(p.id), login: p.login || '', name: p.name || p.login || 'Builder', avatar: p.avatar_url || null, agent: !!p.agent, build: row('build'), art: row('art'), idea: row('idea'), gov: row('gov') };
|
|
89
|
+
});
|
|
90
|
+
const now = d.now || {};
|
|
91
|
+
const sites = {};
|
|
92
|
+
(Array.isArray(now.sites) ? now.sites : []).forEach((s) => { if (s && s.goal_id != null && s.builder_id != null) sites[String(s.goal_id)] = { builder: String(s.builder_id), agent: !!s.agent }; });
|
|
93
|
+
const waiting = {};
|
|
94
|
+
(Array.isArray(now.waiting) ? now.waiting : []).forEach((w) => { if (w && w.goal_id != null && num(w.count) > 0) waiting[String(w.goal_id)] = num(w.count); });
|
|
95
|
+
const ideas = (Array.isArray(d.ideas) ? d.ideas : []).map((x) => ({ id: String(x.id), week: num(x.week), by: x.by != null ? String(x.by) : null, title: x.title == null ? 'A private idea' : String(x.title), goal: x.goal_id != null ? String(x.goal_id) : null }));
|
|
96
|
+
return {
|
|
97
|
+
weeks, W, areas, rows, unplaced: all.length - areas.length,
|
|
98
|
+
px: TOWER_PX / tallest, people, sites, waiting, ideas,
|
|
99
|
+
width: 16.2, depth: rows * ROW_GAP - 0.6,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What an area has built by the end of week i (the week's own ships included).
|
|
104
|
+
function doneAt(a, i) {
|
|
105
|
+
let build = a.baseBuild, art = a.baseArt;
|
|
106
|
+
for (let w = 0; w <= i && w < a.build.length; w++) { build += a.build[w]; art += a.art[w]; }
|
|
107
|
+
return { build, art };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The sites worked in week i, goal → { builder, agent }: the latest week shows
|
|
111
|
+
// who is on each site now (open claims; an Autobongos runner working one makes it
|
|
112
|
+
// an agent's site); an earlier week shows its four busiest areas and the builder
|
|
113
|
+
// who shipped most there.
|
|
114
|
+
function sitesFor(m, i) {
|
|
115
|
+
if (i === m.W - 1 && Object.keys(m.sites).length) return Object.assign({}, m.sites);
|
|
116
|
+
const out = {};
|
|
117
|
+
m.areas.map((a) => ({ a, n: a.build[i] + a.art[i] }))
|
|
118
|
+
.filter((x) => x.n > 0 && x.a.lead[i])
|
|
119
|
+
.sort((p, q) => q.n - p.n || p.a.id.localeCompare(q.a.id))
|
|
120
|
+
.slice(0, 4)
|
|
121
|
+
.forEach((x) => { out[x.a.id] = { builder: x.a.lead[i], agent: false }; });
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The ladder for week i: builders by what they did that week, the top seven,
|
|
126
|
+
// and the viewer appended when they fall below (a phone leaderboard's "you").
|
|
127
|
+
function ladderFor(m, i, viewerId) {
|
|
128
|
+
const rows = Object.values(m.people).map((p) => {
|
|
129
|
+
const v = { art: p.art[i] || 0, build: p.build[i] || 0, idea: p.idea[i] || 0, gov: p.gov[i] || 0 };
|
|
130
|
+
return { id: p.id, name: p.name, avatar: p.avatar, agent: p.agent, me: viewerId != null && p.id === String(viewerId), v, total: v.art + v.build + v.idea + v.gov };
|
|
131
|
+
}).filter((r) => r.total > 0 || r.me)
|
|
132
|
+
.sort((p, q) => q.total - p.total || p.name.localeCompare(q.name));
|
|
133
|
+
const top = rows.slice(0, LADDER_ROWS);
|
|
134
|
+
const me = rows.find((r) => r.me);
|
|
135
|
+
if (me && !top.includes(me)) top.push(me);
|
|
136
|
+
return top;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The scene's viewBox for a model: the ground and the tallest tower plus room
|
|
140
|
+
// for its crane and face, trimmed so no empty sky stands above it.
|
|
141
|
+
function viewBoxOf(m) {
|
|
142
|
+
const OY = TOWER_PX + 110, h = OY + (m.width + m.depth) * TH / 2 + 24;
|
|
143
|
+
return { w: 1000, h: Math.round(h), OX: 500 - (m.width - m.depth) * TW / 4, OY };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The scene for week i as SHAPES, never markup: a tree of { tag, attrs, kids,
|
|
147
|
+
// text } that home-city.js turns into SVG nodes with createElementNS and
|
|
148
|
+
// setAttribute (city-draw.js's split exactly — "city.js only turns the lists into
|
|
149
|
+
// SVG nodes"). So no ledger string is ever parsed as markup: a title is an
|
|
150
|
+
// attribute value or a text node, which is also why this file needs no escaper.
|
|
151
|
+
function citySvg(m, i, opts) {
|
|
152
|
+
const o = opts || {};
|
|
153
|
+
const I = theme(o.mode);
|
|
154
|
+
const vb = viewBoxOf(m);
|
|
155
|
+
const latest = i === m.W - 1;
|
|
156
|
+
const P = (x, y, z) => [vb.OX + (x - y) * TW / 2, vb.OY + (x + y) * TH / 2 - (z || 0)];
|
|
157
|
+
const pts = (a) => 'M' + a.map((p) => r1(p[0]) + ' ' + r1(p[1])).join('L') + 'Z';
|
|
158
|
+
const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
|
|
159
|
+
const face = (a, fill, stroke) => node('path', { d: pts(a), fill, stroke: stroke || 'none', 'stroke-width': 1, 'stroke-linejoin': 'round', class: 'hc-face' });
|
|
160
|
+
const line = (a, b, s, w, dash) => node('line', Object.assign({ x1: r1(a[0]), y1: r1(a[1]), x2: r1(b[0]), y2: r1(b[1]), stroke: s, 'stroke-width': w || 1, 'stroke-linecap': 'round' }, dash ? { 'stroke-dasharray': dash } : {}));
|
|
161
|
+
const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r }, attrs));
|
|
162
|
+
const box = (x0, y0, x1, y1, z0, h, t) => {
|
|
163
|
+
const z1 = z0 + h;
|
|
164
|
+
return [
|
|
165
|
+
face([P(x0, y1, z1), P(x1, y1, z1), P(x1, y1, z0), P(x0, y1, z0)], t.l, t.s),
|
|
166
|
+
face([P(x1, y1, z1), P(x1, y0, z1), P(x1, y0, z0), P(x1, y1, z0)], t.r, t.s),
|
|
167
|
+
face([P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)], t.t, t.s),
|
|
168
|
+
];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const out = [
|
|
172
|
+
node('defs', {}, [node('radialGradient', { id: 'hc-orb' }, [node('stop', { offset: 0, 'stop-color': I.orbGlow }), node('stop', { offset: 1, 'stop-color': 'transparent' })])]),
|
|
173
|
+
face([P(0, 0), P(m.width, 0), P(m.width, m.depth), P(0, m.depth)], I.ground, I.groundLine),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const sites = sitesFor(m, i);
|
|
177
|
+
const tops = {}, over = [];
|
|
178
|
+
m.areas.slice().sort((p, q) => (p.x + p.y) - (q.x + q.y)).forEach((a) => {
|
|
179
|
+
const x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
|
|
180
|
+
const d = doneAt(a, i), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
|
|
181
|
+
const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
|
|
182
|
+
const wait = latest ? (m.waiting[a.id] || 0) : 0;
|
|
183
|
+
const g = [];
|
|
184
|
+
g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
|
|
185
|
+
g.push(...box(x0, y0, x1, y1, base, built, I.work));
|
|
186
|
+
if (clad > 1) {
|
|
187
|
+
const c = base + Math.min(clad, built);
|
|
188
|
+
g.push(face([P(x0, y1, c), P(x1, y1, c), P(x1, y1, base), P(x0, y1, base)], I.glassL));
|
|
189
|
+
g.push(face([P(x1, y1, c), P(x1, y0, c), P(x1, y0, base), P(x1, y1, base)], I.glassR));
|
|
190
|
+
if (clad >= built - 0.5) g.push(face([P(x0, y0, base + built), P(x1, y0, base + built), P(x1, y1, base + built), P(x0, y1, base + built)], I.glassT));
|
|
191
|
+
}
|
|
192
|
+
for (let z = base + 16; z < base + built - 2; z += 16) g.push(line(P(x0, y1, z), P(x1, y1, z), I.floor), line(P(x1, y1, z), P(x1, y0, z), I.floor));
|
|
193
|
+
const top = base + built;
|
|
194
|
+
tops[a.id] = P((x0 + x1) / 2, (y0 + y1) / 2, top);
|
|
195
|
+
if (site) {
|
|
196
|
+
// the floor going up: lit, wrapped in scaffolding, a crane lifting the next slab
|
|
197
|
+
if (built > 14) {
|
|
198
|
+
const zt = top - 9, zb = top - 3;
|
|
199
|
+
g.push(face([P(x0 + 0.15, y1, zb), P(x1 - 0.15, y1, zb), P(x1 - 0.15, y1, zt), P(x0 + 0.15, y1, zt)], I.lit));
|
|
200
|
+
g.push(face([P(x1, y1 - 0.15, zb), P(x1, y0 + 0.15, zb), P(x1, y0 + 0.15, zt), P(x1, y1 - 0.15, zt)], I.lit));
|
|
201
|
+
}
|
|
202
|
+
const ov = 0.14, s0 = Math.max(base, top - 12), s1 = top + 22;
|
|
203
|
+
[x0 - ov, (x0 + x1) / 2, x1 + ov].forEach((x) => g.push(line(P(x, y1 + ov, s0), P(x, y1 + ov, s1), I.scaffold)));
|
|
204
|
+
[y1 + ov, (y0 + y1) / 2, y0 - ov].forEach((y) => g.push(line(P(x1 + ov, y, s0), P(x1 + ov, y, s1), I.scaffold)));
|
|
205
|
+
for (let zz = s0; zz <= s1 + 0.1; zz += 11) g.push(line(P(x0 - ov, y1 + ov, zz), P(x1 + ov, y1 + ov, zz), I.scaffold), line(P(x1 + ov, y1 + ov, zz), P(x1 + ov, y0 - ov, zz), I.scaffold));
|
|
206
|
+
const mx = x0 + 0.5, my = y0 + 0.5, ct = top + 56;
|
|
207
|
+
g.push(line(P(mx - 0.08, my, top), P(mx - 0.08, my, ct), I.steel, 1.3), line(P(mx + 0.08, my, top), P(mx + 0.08, my, ct), I.steel, 1.3));
|
|
208
|
+
for (let zc = top, k = 0; zc + 8 <= ct; zc += 8, k++) g.push(line(P(mx + (k % 2 ? 0.08 : -0.08), my, zc), P(mx + (k % 2 ? -0.08 : 0.08), my, zc + 8), I.steel, 0.8));
|
|
209
|
+
g.push(line(P(mx - 0.9, my, ct), P(mx + 2.2, my, ct), I.steel, 1.5));
|
|
210
|
+
g.push(line(P(mx, my, ct + 11), P(mx + 2.2, my, ct), I.steel, 0.9), line(P(mx, my, ct + 11), P(mx - 0.9, my, ct), I.steel, 0.9));
|
|
211
|
+
g.push(...box(mx - 0.95, my - 0.12, mx - 0.6, my + 0.12, ct - 8, 8, I.slab));
|
|
212
|
+
const hx = mx + 1.8;
|
|
213
|
+
g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
|
|
214
|
+
g.push(node('g', { class: 'hc-slab' }, box(hx - 0.3, my - 0.2, hx + 0.3, my + 0.2, top - 38, 3, I.slab)));
|
|
215
|
+
if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
|
|
216
|
+
}
|
|
217
|
+
if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
|
|
218
|
+
const tip = `${d.build + d.art} shipped${d.art ? ` · ${d.art} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''}`;
|
|
219
|
+
out.push(node('g', { class: 'hc-tower', 'data-tip': a.title, 'data-tip-body': tip }, g));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// the sky: the week's ideas bright, the week before dim; one that became work
|
|
223
|
+
// drops a line to the tower it went into
|
|
224
|
+
m.ideas.forEach((x) => {
|
|
225
|
+
const age = i - x.week;
|
|
226
|
+
if (age < 0 || age > 1) return;
|
|
227
|
+
const h = hash(x.id), bright = age === 0;
|
|
228
|
+
const cx = vb.OX + 40 + (h % 1000) / 1000 * 300, cy = 40 + (Math.floor(h / 1000) % 1000) / 1000 * 150;
|
|
229
|
+
const by = x.by && m.people[x.by] ? m.people[x.by].name : '';
|
|
230
|
+
const g = [];
|
|
231
|
+
if (bright && x.goal && tops[x.goal]) g.push(line([cx, cy], tops[x.goal], I.ideaLine, 1, '2 4'));
|
|
232
|
+
g.push(circle(cx, cy, bright ? 14 : 9, Object.assign({ fill: 'url(#hc-orb)' }, bright ? { class: 'hc-orb' } : {})));
|
|
233
|
+
g.push(circle(cx, cy, bright ? 4 : 2.6, Object.assign({ fill: I.orb }, bright ? {} : { opacity: 0.45 })));
|
|
234
|
+
g.push(circle(cx, cy, 12, { fill: 'transparent' }));
|
|
235
|
+
out.push(node('g', { class: 'hc-idea', 'data-tip': x.title, 'data-tip-body': by ? `idea · ${by}` : 'idea' }, g));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
over.forEach((v, n) => {
|
|
239
|
+
if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
|
|
240
|
+
const cx = v.p[0] - 30, cy = v.p[1];
|
|
241
|
+
out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
|
|
242
|
+
circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
|
|
243
|
+
node('text', { x: r1(cx), y: r1(cy + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], v.n),
|
|
244
|
+
]));
|
|
245
|
+
});
|
|
246
|
+
return { viewBox: `0 0 ${vb.w} ${vb.h}`, nodes: out };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A face over a crane: the builder's avatar clipped to a circle over their
|
|
250
|
+
// initial (so a picture that fails to load still leaves a face), or the robot
|
|
251
|
+
// glyph when an agent is building the site.
|
|
252
|
+
function avatarNode(p, who, agent, a, I, n, node, line, circle) {
|
|
253
|
+
const x = p[0], y = p[1], id = `hc-av-${n}`;
|
|
254
|
+
const g = [line([x, y + 16], [x, y + 28], I.steel, 1), circle(x, y, 16, { fill: agent ? I.avAgent : I.av, stroke: 'var(--bg-card)', 'stroke-width': 3 })];
|
|
255
|
+
if (agent) {
|
|
256
|
+
g.push(node('rect', { x: r1(x - 7), y: r1(y - 5), width: 14, height: 11, rx: 3, fill: 'none', stroke: I.avInk, 'stroke-width': 1.5 }));
|
|
257
|
+
g.push(circle(x - 3, y + 0.5, 1.5, { fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { fill: I.avInk }));
|
|
258
|
+
} else {
|
|
259
|
+
g.push(node('text', { x: r1(x), y: r1(y + 5), 'text-anchor': 'middle', fill: I.avInk, class: 'hc-av__i' }, [], initial(who.name)));
|
|
260
|
+
if (who.avatar && /^https:\/\//.test(who.avatar)) {
|
|
261
|
+
g.push(node('clipPath', { id }, [circle(x, y, 14.5, {})]));
|
|
262
|
+
g.push(node('image', { href: who.avatar, x: r1(x - 14.5), y: r1(y - 14.5), width: 29, height: 29, 'clip-path': `url(#${id})`, preserveAspectRatio: 'xMidYMid slice' }));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
|
|
269
|
+
// a stable spread for an idea's place in the sky (FNV-1a over its id)
|
|
270
|
+
function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return Math.abs(h); }
|
|
271
|
+
|
|
272
|
+
const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS };
|
|
273
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
274
|
+
if (root) root.OTBHomeCityDraw = api;
|
|
275
|
+
})(typeof window !== 'undefined' ? window : null);
|