@bongos/core 1.24.29 → 1.24.31
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 +198 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +174 -1
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +226 -224
- package/docs/copy-registry.json +583 -549
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +18 -37
- package/docs/page-readings.json +306 -361
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
- 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/ai-studio.html +1 -1
- 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/builders.js +3 -79
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +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.html +1 -1
- 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/hall-render.js +68 -167
- package/modules/hall-ui/public/hall-widgets.js +4 -45
- package/modules/hall-ui/public/home-city-draw.js +583 -0
- package/modules/hall-ui/public/home-city.css +102 -0
- package/modules/hall-ui/public/home-city.js +339 -0
- package/modules/hall-ui/public/home-gallery.css +129 -0
- package/modules/hall-ui/public/home-gallery.js +349 -0
- package/modules/hall-ui/public/home-studio.css +72 -0
- package/modules/hall-ui/public/home-studio.js +203 -0
- 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 +9 -7
- package/modules/hall-ui/public/index.states.json +73 -8
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.js +6 -1
- package/modules/hall-ui/public/market-watch.html +141 -54
- package/modules/hall-ui/public/market-watch.js +54 -0
- package/modules/hall-ui/public/market-watch.states.json +2 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.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 +2 -3
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +9 -1
- package/modules/hall-ui/public/style.css +74 -27
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/home-city.md +189 -0
- package/modules/hall-ui/records/loader.md +1 -1
- package/modules/hall-ui/records/market-watch-settings.md +16 -3
- package/modules/hall-ui/records/market-watch.md +3 -1
- package/modules/lifecycle/home-city.js +388 -0
- package/modules/lifecycle/home-gallery.js +320 -0
- package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
- package/modules/lifecycle/module.json +2 -0
- package/modules/lifecycle/routes/home-city.js +29 -0
- package/modules/lifecycle/routes/home-gallery.js +37 -0
- package/modules/market-watch/routes/settings.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +38 -0
- package/scripts/bongos/leak-scan-allowlist.js +8 -0
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +10 -0
- package/src/bongos/serve-internal.js +17 -3
- 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_builders_page_pass.mjs +18 -27
- package/tests/hall_home_ships_bounded.mjs +4 -3
- package/tests/hall_loading.mjs +1 -1
- package/tests/hall_nav.mjs +11 -4
- package/tests/hall_page_gate_map.mjs +7 -4
- package/tests/home_bounded_reads.mjs +16 -31
- package/tests/home_city.mjs +580 -0
- package/tests/home_city_widget.mjs +260 -0
- package/tests/home_gallery.mjs +255 -0
- package/tests/home_gallery_widget.mjs +220 -0
- package/tests/home_studio.mjs +169 -0
- package/tests/home_studio_widget.mjs +157 -0
- package/tests/leaderboard_roles.mjs +3 -2
- package/tests/market_watch_pages.mjs +162 -0
- package/tests/nav_permission_atoms.mjs +2 -2
- package/modules/hall-ui/public/market-watch-settings.html +0 -115
- package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054;
|
|
2
|
+
// v16, task 1005058).
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS. The drawing half of Home's city (the owner's v14 mock,
|
|
5
|
+
// docs/design/mocks/hall/home/v14.html): one isometric tower per working area,
|
|
6
|
+
// as tall as what has shipped there; artist work clads it in glass; the sites
|
|
7
|
+
// worked in a week carry a crane and the face of who built there; that week's
|
|
8
|
+
// ideas are lights in the sky; a salmon dot marks where something waits on the
|
|
9
|
+
// viewer. Beside it, the ladder: each builder's week as one line split by craft.
|
|
10
|
+
// home-city.js owns the page half (the read, the slider, the ladder's DOM,
|
|
11
|
+
// hover); this file only turns the weekly summary into shapes and rows.
|
|
12
|
+
//
|
|
13
|
+
// v16 (the owner's round of 2026-11-04, docs/design/mocks/hall/home/v16-building.html):
|
|
14
|
+
// ONE city for every craft. The Governor City's nine civic buildings stand at the
|
|
15
|
+
// centre, drawn by city-draw.js itself (GovCity.isoInstitution at GovCity.CIVIC_SLOTS,
|
|
16
|
+
// handed in as opts.gov — never copied), always built: the Cloud Bongos core. The
|
|
17
|
+
// working areas stand on a ring of plots around it, tallest at the back so none hides
|
|
18
|
+
// the core; the art museum and its garden (after the Cleveland Museum of Art) stand on
|
|
19
|
+
// the front edge. On the builder's Home the core and the museum are faded so the new
|
|
20
|
+
// buildings lead (opts.focus names the one district that stays in colour, for the
|
|
21
|
+
// other crafts' Homes). Each district is ONE link: the core opens the street, the
|
|
22
|
+
// museum the artist's Home, the band of stars high above the city the ideator's
|
|
23
|
+
// sky; a working area's building opens its goal. The week may be fractional — the
|
|
24
|
+
// slider glides through the weeks in between, so a building grows smoothly.
|
|
25
|
+
//
|
|
26
|
+
// THE IDEATOR'S SKY (task 1005059, docs/design/mocks/hall/home/v16-ideating.html): the
|
|
27
|
+
// same city seen from the street. skylineOf turns every building citySvg stands — the
|
|
28
|
+
// working areas, the nine civic buildings, the museum — into a silhouette at the SAME
|
|
29
|
+
// left-to-right place (the iso screen x of its footprint) and the same height, drawn
|
|
30
|
+
// back to front so a nearer building covers the one behind it, outline included. Over
|
|
31
|
+
// it skySvg hangs the ideas: a constellation per working area, a field for the ones no
|
|
32
|
+
// area holds yet, size by votes, a salmon ring on what waits on the viewer, a falling
|
|
33
|
+
// star and a lit window for an idea that became work; the moon is the version being
|
|
34
|
+
// built, its phase how far along. Three hit groups lie over the skyline, one door each.
|
|
35
|
+
//
|
|
36
|
+
// PURE. No DOM, no fetch, no clock: the same summary and week give the same
|
|
37
|
+
// shapes, so tests run it in node (the roadmap-timeline.js precedent). The scene
|
|
38
|
+
// comes back as a tree of plain shape objects, never markup (see citySvg).
|
|
39
|
+
//
|
|
40
|
+
// THE PALETTE is the city's own drawing data (city-draw.js isoTheme, trimmed to
|
|
41
|
+
// what this scene spends: the dark blocks in the black, the white paper model in
|
|
42
|
+
// light). Everything around the drawing takes the hall's tokens; salmon
|
|
43
|
+
// (var(--accent)) is spent only on what waits on the viewer.
|
|
44
|
+
(function (root) {
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const FOOT = 1.8; // a tower's footprint, in tiles
|
|
48
|
+
const GROUND = { w: 16, d: 19.4 }; // the city's ground, in tiles; the garden runs past y 16
|
|
49
|
+
const CORE = { x0: 4.3, y0: 4.3, x1: 11.7, y1: 11.7 }; // the civic plaza
|
|
50
|
+
const CIVIC_S = 0.82; // the civic buildings' height scale (city-draw.js kit units)
|
|
51
|
+
// The ring of plots around the core: the back edges (y≈0 and x≈0) first, so the tallest
|
|
52
|
+
// areas stand where they hide nothing, then the sides, then the two front corners.
|
|
53
|
+
const RING = (function () {
|
|
54
|
+
const p = [];
|
|
55
|
+
[0.7, 3.3, 5.9, 8.5, 11.1, 13.7].forEach((x) => p.push([x, 0.7]));
|
|
56
|
+
[3.3, 5.9, 8.5, 11.1].forEach((y) => { p.push([0.7, y]); p.push([13.7, y]); });
|
|
57
|
+
p.push([0.7, 13.7], [13.7, 13.7]);
|
|
58
|
+
return p.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]) || a[0] - b[0]);
|
|
59
|
+
})();
|
|
60
|
+
const MAX_AREAS = RING.length; // sixteen; the rest are reported, not drawn
|
|
61
|
+
const TOWER_PX = 150; // the tallest tower at the latest week
|
|
62
|
+
const TW = 50, TH = 25; // iso tile, in viewBox units
|
|
63
|
+
const SKY_H = 84; // the band of stars above the city
|
|
64
|
+
const LADDER_ROWS = 7;
|
|
65
|
+
const SKY = { w: 1000, h: 560 }; // the ideator's sky; the skyline stands on its bottom edge
|
|
66
|
+
const SKY_SLOTS = [[160, 95], [400, 78], [640, 92], [280, 205], [520, 190], [110, 310], [370, 318], [790, 176], [60, 196], [900, 270]];
|
|
67
|
+
const LOOSE = { cx: 690, cy: 312, rx: 150, ry: 54 };
|
|
68
|
+
const MOON = { x: 905, y: 70, r: 28 };
|
|
69
|
+
// the museum as the skyline sees it: the marble block with its portico, the atrium behind
|
|
70
|
+
const MUSEUM_SIL = [{ k: 'museum', x0: 4.9, y0: 12.4, x1: 11.1, y1: 13.7, h: 43, portico: true }, { k: 'museum', x0: 5.6, y0: 10.9, x1: 10.4, y1: 12.3, h: 40 }];
|
|
71
|
+
|
|
72
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
73
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
74
|
+
const sum = (a) => (Array.isArray(a) ? a.reduce((s, v) => s + num(v), 0) : 0);
|
|
75
|
+
|
|
76
|
+
// The sky's own drawing data, as theme() is the city's: night over black, dusk over paper.
|
|
77
|
+
function skyTheme(mode) {
|
|
78
|
+
if (mode === 'light') {
|
|
79
|
+
return { star: '#b8732f', glow: 'rgba(214,140,70,0.28)', line: 'rgba(150,95,40,0.38)', label: 'rgba(23,24,26,0.58)', near: '#b9b6c2', far: '#d4d2dc', civic: '#c9bfb2', win: 'rgba(214,140,70,0.85)', moon: '#3b3f52', shade: '#e8eaf3', streak: 'rgba(214,140,70,0.9)', field: 'rgba(23,24,26,0.2)', beacon: '#fb9569', outline: '#8a5523' };
|
|
80
|
+
}
|
|
81
|
+
return { star: '#ffe2c2', glow: 'rgba(255,214,170,0.35)', line: 'rgba(255,214,170,0.32)', label: 'rgba(255,255,255,0.5)', near: '#060609', far: '#14141d', civic: '#0d0c12', win: 'rgba(255,214,160,0.9)', moon: '#f4ead8', shade: '#08080e', streak: 'rgba(255,226,194,0.95)', field: 'rgba(255,255,255,0.18)', beacon: '#fff4e6', outline: '#ffe2c2' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function theme(mode) {
|
|
85
|
+
if (mode === 'light') {
|
|
86
|
+
return {
|
|
87
|
+
work: { t: '#f6f5f8', l: '#e6e5e9', r: '#d2d1d6', s: 'rgba(23,24,26,0.22)' },
|
|
88
|
+
plinth: { t: '#efeef1', l: '#e2e1e5', r: '#cfced3', s: 'rgba(23,24,26,0.22)' },
|
|
89
|
+
glassL: 'rgba(91,111,208,0.30)', glassR: 'rgba(91,111,208,0.42)', glassT: 'rgba(91,111,208,0.22)',
|
|
90
|
+
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)',
|
|
91
|
+
slab: { t: '#d9d8dd', l: '#c4c3c9', r: '#b2b1b8', s: 'rgba(23,24,26,0.4)' },
|
|
92
|
+
ground: 'rgba(23,24,26,0.035)', groundLine: 'rgba(23,24,26,0.08)',
|
|
93
|
+
orb: '#d98a4e', orbGlow: 'rgba(217,138,78,0.30)', ideaLine: 'rgba(217,138,78,0.55)',
|
|
94
|
+
av: '#e4e3e7', avAgent: '#d3d8f2', avInk: '#17181a',
|
|
95
|
+
star: '#b8732f',
|
|
96
|
+
marble: { t: '#fbfaf7', l: '#ece9e2', r: '#d8d4cb', s: 'rgba(23,24,26,0.28)' }, granite: { t: '#cfcdd2', l: '#bcbac0', r: '#a9a7ae', s: 'rgba(23,24,26,0.25)' },
|
|
97
|
+
mglass: { t: 'rgba(91,111,208,0.16)', l: 'rgba(91,111,208,0.22)', r: 'rgba(91,111,208,0.3)', s: 'rgba(60,80,180,0.5)' },
|
|
98
|
+
grass: '#c9ddbf', grassLine: 'rgba(40,70,40,0.2)', path: '#ece6da', water: '#a9cbe3', tree: ['#8fb27c', '#7aa36b', '#9dbd89'], col: '#ffffff', dark: 'rgba(23,24,26,0.35)',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
work: { t: '#34343a', l: '#1c1c20', r: '#111114', s: 'rgba(255,255,255,0.12)' },
|
|
103
|
+
plinth: { t: '#27272c', l: '#141417', r: '#0b0b0d', s: 'rgba(255,255,255,0.08)' },
|
|
104
|
+
glassL: 'rgba(110,130,230,0.30)', glassR: 'rgba(110,130,230,0.20)', glassT: 'rgba(150,170,255,0.30)',
|
|
105
|
+
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)',
|
|
106
|
+
slab: { t: '#5a5a62', l: '#3a3a40', r: '#2a2a2f', s: 'rgba(255,255,255,0.2)' },
|
|
107
|
+
ground: 'rgba(255,255,255,0.018)', groundLine: 'rgba(255,255,255,0.05)',
|
|
108
|
+
orb: '#ffe2c2', orbGlow: 'rgba(255,214,170,0.35)', ideaLine: 'rgba(255,214,170,0.45)',
|
|
109
|
+
av: '#2c2c32', avAgent: '#2b3157', avInk: '#ffffff',
|
|
110
|
+
star: '#ffe2c2',
|
|
111
|
+
marble: { t: '#dcd8d0', l: '#bfbab0', r: '#9f9a90', s: 'rgba(255,255,255,0.22)' }, granite: { t: '#3c3c42', l: '#28282d', r: '#1c1c20', s: 'rgba(255,255,255,0.12)' },
|
|
112
|
+
mglass: { t: 'rgba(150,170,255,0.24)', l: 'rgba(120,140,230,0.2)', r: 'rgba(100,120,210,0.16)', s: 'rgba(170,190,255,0.45)' },
|
|
113
|
+
grass: '#273f2d', grassLine: 'rgba(160,220,160,0.10)', path: '#3d3a36', water: '#34577a', tree: ['#4f7a4c', '#43683f', '#5b8656'], col: '#e8e4dc', dark: 'rgba(0,0,0,0.55)',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The summary as the drawing needs it: areas placed on the ground (tallest at
|
|
118
|
+
// the back), one shared height scale, people by id. A summary the server could
|
|
119
|
+
// not fill (no version being built, no areas) gives a model with no areas and
|
|
120
|
+
// the page renders nothing rather than an empty plate.
|
|
121
|
+
function cityModel(data) {
|
|
122
|
+
const d = data || {};
|
|
123
|
+
const weeks = Array.isArray(d.weeks) ? d.weeks : [];
|
|
124
|
+
const W = weeks.length;
|
|
125
|
+
const all = (Array.isArray(d.areas) ? d.areas : []).map((a) => {
|
|
126
|
+
const build = Array.from({ length: W }, (_, i) => num(a.build && a.build[i]));
|
|
127
|
+
const art = Array.from({ length: W }, (_, i) => num(a.art && a.art[i]));
|
|
128
|
+
const lead = Array.from({ length: W }, (_, i) => (a.lead && a.lead[i] != null ? String(a.lead[i]) : null));
|
|
129
|
+
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 };
|
|
130
|
+
});
|
|
131
|
+
all.forEach((a) => { a.total = a.baseBuild + a.baseArt + sum(a.build) + sum(a.art); });
|
|
132
|
+
all.sort((p, q) => q.total - p.total || p.id.localeCompare(q.id));
|
|
133
|
+
const areas = all.slice(0, MAX_AREAS);
|
|
134
|
+
areas.forEach((a, n) => { a.x = RING[n][0]; a.y = RING[n][1]; });
|
|
135
|
+
const tallest = Math.max(1, ...areas.map((a) => a.total));
|
|
136
|
+
const people = {};
|
|
137
|
+
(Array.isArray(d.people) ? d.people : []).forEach((p) => {
|
|
138
|
+
const row = (k) => Array.from({ length: W }, (_, i) => num(p[k] && p[k][i]));
|
|
139
|
+
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'), vote: row('vote') };
|
|
140
|
+
});
|
|
141
|
+
const now = d.now || {};
|
|
142
|
+
const sites = {};
|
|
143
|
+
(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 }; });
|
|
144
|
+
const waiting = {};
|
|
145
|
+
(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); });
|
|
146
|
+
const ideas = (Array.isArray(d.ideas) ? d.ideas : []).map((x) => ({
|
|
147
|
+
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),
|
|
148
|
+
goal: x.goal_id != null ? String(x.goal_id) : null,
|
|
149
|
+
area: x.area_id != null ? String(x.area_id) : (x.goal_id != null ? String(x.goal_id) : null),
|
|
150
|
+
became: x.became != null && x.goal_id != null && Number.isFinite(Number(x.became)) ? Number(x.became) : null,
|
|
151
|
+
votes: Math.max(0, num(x.votes)), waits: !!x.waits,
|
|
152
|
+
}));
|
|
153
|
+
const v = d.version || null;
|
|
154
|
+
const version = v ? { name: String(v.name || v.id || 'The version'), tasks: Number.isFinite(Number(v.tasks)) && Number(v.tasks) > 0 ? Number(v.tasks) : null } : null;
|
|
155
|
+
return {
|
|
156
|
+
weeks, W, areas, unplaced: all.length - areas.length,
|
|
157
|
+
px: TOWER_PX / tallest, people, sites, waiting, ideas, version,
|
|
158
|
+
width: GROUND.w, depth: GROUND.d,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// What an area has built by the end of week i (the week's own ships included). A
|
|
163
|
+
// fractional i is part-way into the next week, so the slider's glide grows a tower
|
|
164
|
+
// smoothly instead of in weekly jumps.
|
|
165
|
+
function doneAt(a, i) {
|
|
166
|
+
let build = a.baseBuild, art = a.baseArt;
|
|
167
|
+
const whole = Math.floor(i), f = i - whole;
|
|
168
|
+
for (let w = 0; w <= whole && w < a.build.length; w++) { build += a.build[w]; art += a.art[w]; }
|
|
169
|
+
if (f > 0 && whole + 1 < a.build.length) { build += a.build[whole + 1] * f; art += a.art[whole + 1] * f; }
|
|
170
|
+
return { build, art };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The sites worked in week i, goal → { builder, agent }: the latest week shows
|
|
174
|
+
// who is on each site now (open claims; an Autobongos runner working one makes it
|
|
175
|
+
// an agent's site); an earlier week shows its four busiest areas and the builder
|
|
176
|
+
// who shipped most there.
|
|
177
|
+
function sitesFor(m, i) {
|
|
178
|
+
if (i === m.W - 1 && Object.keys(m.sites).length) return Object.assign({}, m.sites);
|
|
179
|
+
const out = {};
|
|
180
|
+
m.areas.map((a) => ({ a, n: a.build[i] + a.art[i] }))
|
|
181
|
+
.filter((x) => x.n > 0 && x.a.lead[i])
|
|
182
|
+
.sort((p, q) => q.n - p.n || p.a.id.localeCompare(q.a.id))
|
|
183
|
+
.slice(0, 4)
|
|
184
|
+
.forEach((x) => { out[x.a.id] = { builder: x.a.lead[i], agent: false }; });
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The ladder for week i: builders by what they did that week, the top seven,
|
|
189
|
+
// and the viewer appended when they fall below (a phone leaderboard's "you").
|
|
190
|
+
// On the ideator's Home (craft 'ideating') the line is the ideator's week instead:
|
|
191
|
+
// ideas filed, votes cast, and ideas of theirs that became work that week.
|
|
192
|
+
function ladderFor(m, i, viewerId, craft) {
|
|
193
|
+
const rows = Object.values(m.people).map((p) => {
|
|
194
|
+
const v = craft === 'ideating'
|
|
195
|
+
? { idea: p.idea[i] || 0, vote: (p.vote && p.vote[i]) || 0, work: m.ideas.filter((x) => x.by === p.id && x.became === i).length }
|
|
196
|
+
: { art: p.art[i] || 0, build: p.build[i] || 0, idea: p.idea[i] || 0, gov: p.gov[i] || 0 };
|
|
197
|
+
return { id: p.id, name: p.name, avatar: p.avatar, agent: p.agent, me: viewerId != null && p.id === String(viewerId), v, total: Object.values(v).reduce((a, b) => a + b, 0) };
|
|
198
|
+
}).filter((r) => r.total > 0 || r.me)
|
|
199
|
+
.sort((p, q) => q.total - p.total || p.name.localeCompare(q.name));
|
|
200
|
+
const top = rows.slice(0, LADDER_ROWS);
|
|
201
|
+
const me = rows.find((r) => r.me);
|
|
202
|
+
if (me && !top.includes(me)) top.push(me);
|
|
203
|
+
return top;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The scene's viewBox for a model: the ground and the tallest tower plus room
|
|
207
|
+
// for its crane and face, trimmed so no empty sky stands above it.
|
|
208
|
+
function viewBoxOf(m) {
|
|
209
|
+
const OY = SKY_H + TOWER_PX + 100, h = OY + (m.width + m.depth) * TH / 2 + 16;
|
|
210
|
+
return { w: 1000, h: Math.round(h), OX: 500 - (m.width - m.depth) * TW / 4, OY };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The scene for week i as SHAPES, never markup: a tree of { tag, attrs, kids,
|
|
214
|
+
// text } that home-city.js turns into SVG nodes with createElementNS and
|
|
215
|
+
// setAttribute (city-draw.js's split exactly — "city.js only turns the lists into
|
|
216
|
+
// SVG nodes"). So no ledger string is ever parsed as markup: a title is an
|
|
217
|
+
// attribute value or a text node, which is also why this file needs no escaper.
|
|
218
|
+
function citySvg(m, t, opts) {
|
|
219
|
+
const o = opts || {};
|
|
220
|
+
const I = theme(o.mode);
|
|
221
|
+
const Gov = o.gov || null;
|
|
222
|
+
const vb = viewBoxOf(m);
|
|
223
|
+
const i = Math.max(0, Math.min(m.W - 1, Math.floor(t + 1e-6)));
|
|
224
|
+
const latest = t >= m.W - 1 - 1e-3;
|
|
225
|
+
const P = (x, y, z) => [vb.OX + (x - y) * TW / 2, vb.OY + (x + y) * TH / 2 - (z || 0)];
|
|
226
|
+
const pts = (a) => 'M' + a.map((p) => r1(p[0]) + ' ' + r1(p[1])).join('L') + 'Z';
|
|
227
|
+
const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
|
|
228
|
+
const face = (a, fill, stroke) => node('path', { d: pts(a), fill, stroke: stroke || 'none', 'stroke-width': 1, 'stroke-linejoin': 'round', class: 'hc-face' });
|
|
229
|
+
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 } : {}));
|
|
230
|
+
const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r }, attrs));
|
|
231
|
+
const box = (x0, y0, x1, y1, z0, h, tn) => {
|
|
232
|
+
const z1 = z0 + h;
|
|
233
|
+
return [
|
|
234
|
+
face([P(x0, y1, z1), P(x1, y1, z1), P(x1, y1, z0), P(x0, y1, z0)], tn.l, tn.s),
|
|
235
|
+
face([P(x1, y1, z1), P(x1, y0, z1), P(x1, y0, z0), P(x1, y1, z0)], tn.r, tn.s),
|
|
236
|
+
face([P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)], tn.t, tn.s),
|
|
237
|
+
];
|
|
238
|
+
};
|
|
239
|
+
const quiet = (key) => (o.focus === key ? '' : ' hc-quiet');
|
|
240
|
+
|
|
241
|
+
const out = [
|
|
242
|
+
node('defs', {}, [node('radialGradient', { id: 'hc-orb' }, [node('stop', { offset: 0, 'stop-color': I.orbGlow }), node('stop', { offset: 1, 'stop-color': 'transparent' })])]),
|
|
243
|
+
face([P(0, 0), P(m.width, 0), P(m.width, m.depth), P(0, m.depth)], I.ground, I.groundLine),
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
// the ideas: a thin band of stars high above the city, apart from it — one door to the sky
|
|
247
|
+
const seen = m.ideas.filter((x) => x.week <= i);
|
|
248
|
+
const fresh = m.ideas.filter((x) => x.week === i).length;
|
|
249
|
+
const band = [node('rect', { x: 60, y: 8, width: 880, height: SKY_H - 16, fill: 'transparent' })];
|
|
250
|
+
seen.forEach((x) => {
|
|
251
|
+
const h = hash(x.id), recent = i - x.week <= 1;
|
|
252
|
+
band.push(circle(80 + (h % 1000) / 1000 * 840, 14 + (Math.floor(h / 1000) % 1000) / 1000 * (SKY_H - 34), recent ? 1.7 : 1.1, Object.assign({ fill: I.star, opacity: recent ? 0.75 : 0.28 }, x.week === i ? { class: 'hc-twinkle' } : {})));
|
|
253
|
+
});
|
|
254
|
+
out.push(node('a', { href: '/', class: 'hc-door hc-sky', 'data-craft-door': 'ideating', 'aria-label': `The ideas: ${fresh} new this week. Open the sky.`, 'data-tip': 'Ideas', 'data-tip-body': `${fresh} new this week. Click to look up at the sky.` }, band));
|
|
255
|
+
|
|
256
|
+
// every building on the ground, back to front; the civic core draws as one district
|
|
257
|
+
const items = m.areas.map((a) => ({ k: 'area', a, depth: a.x + a.y + FOOT }));
|
|
258
|
+
if (Gov) items.push({ k: 'core', depth: CORE.x0 + CORE.y0 + 1 });
|
|
259
|
+
items.push({ k: 'museum', depth: 15.8 });
|
|
260
|
+
items.sort((p, q) => p.depth - q.depth);
|
|
261
|
+
|
|
262
|
+
const sites = sitesFor(m, i);
|
|
263
|
+
const tops = {}, over = [];
|
|
264
|
+
items.forEach((it) => {
|
|
265
|
+
if (it.k === 'core') { out.push(coreNode()); return; }
|
|
266
|
+
if (it.k === 'museum') { out.push(museumNode()); return; }
|
|
267
|
+
const a = it.a, x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
|
|
268
|
+
const d = doneAt(a, t), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
|
|
269
|
+
const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
|
|
270
|
+
const wait = latest ? (m.waiting[a.id] || 0) : 0;
|
|
271
|
+
const g = [];
|
|
272
|
+
g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
|
|
273
|
+
g.push(...box(x0, y0, x1, y1, base, built, I.work));
|
|
274
|
+
if (clad > 1) {
|
|
275
|
+
const c = base + Math.min(clad, built);
|
|
276
|
+
g.push(face([P(x0, y1, c), P(x1, y1, c), P(x1, y1, base), P(x0, y1, base)], I.glassL));
|
|
277
|
+
g.push(face([P(x1, y1, c), P(x1, y0, c), P(x1, y0, base), P(x1, y1, base)], I.glassR));
|
|
278
|
+
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));
|
|
279
|
+
}
|
|
280
|
+
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));
|
|
281
|
+
const top = base + built;
|
|
282
|
+
tops[a.id] = P((x0 + x1) / 2, (y0 + y1) / 2, top);
|
|
283
|
+
if (site) {
|
|
284
|
+
// the floor going up: lit, wrapped in scaffolding, a crane lifting the next slab
|
|
285
|
+
if (built > 14) {
|
|
286
|
+
const zt = top - 9, zb = top - 3;
|
|
287
|
+
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));
|
|
288
|
+
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));
|
|
289
|
+
}
|
|
290
|
+
const ov = 0.14, s0 = Math.max(base, top - 12), s1 = top + 22;
|
|
291
|
+
[x0 - ov, (x0 + x1) / 2, x1 + ov].forEach((x) => g.push(line(P(x, y1 + ov, s0), P(x, y1 + ov, s1), I.scaffold)));
|
|
292
|
+
[y1 + ov, (y0 + y1) / 2, y0 - ov].forEach((y) => g.push(line(P(x1 + ov, y, s0), P(x1 + ov, y, s1), I.scaffold)));
|
|
293
|
+
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));
|
|
294
|
+
const mx = x0 + 0.5, my = y0 + 0.5, ct = top + 56;
|
|
295
|
+
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));
|
|
296
|
+
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));
|
|
297
|
+
g.push(line(P(mx - 0.9, my, ct), P(mx + 2.2, my, ct), I.steel, 1.5));
|
|
298
|
+
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));
|
|
299
|
+
g.push(...box(mx - 0.95, my - 0.12, mx - 0.6, my + 0.12, ct - 8, 8, I.slab));
|
|
300
|
+
const hx = mx + 1.8;
|
|
301
|
+
g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
|
|
302
|
+
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)));
|
|
303
|
+
if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
|
|
304
|
+
}
|
|
305
|
+
if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
|
|
306
|
+
const shipped = Math.round(d.build + d.art), byArt = Math.round(d.art);
|
|
307
|
+
const tip = `${shipped} shipped${byArt ? ` · ${byArt} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''} · click to open the goal`;
|
|
308
|
+
out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower', 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
over.forEach((v, n) => {
|
|
312
|
+
if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
|
|
313
|
+
const cx = v.p[0] - 30, cy = v.p[1];
|
|
314
|
+
out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
|
|
315
|
+
circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
|
|
316
|
+
node('text', { x: r1(cx), y: r1(cy + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], v.n),
|
|
317
|
+
]));
|
|
318
|
+
});
|
|
319
|
+
return { viewBox: `0 0 ${vb.w} ${vb.h}`, nodes: out };
|
|
320
|
+
|
|
321
|
+
// The government district: the plaza and the nine civic buildings, drawn by
|
|
322
|
+
// city-draw.js's own recipes in its own palette — one hover, one door, to the street.
|
|
323
|
+
function coreNode() {
|
|
324
|
+
const CI = Gov.isoTheme(o.mode === 'light' ? 'light' : 'dark');
|
|
325
|
+
const k = Gov.isoKit(TW, TH, vb.OX, vb.OY, CIVIC_S, CI, o.mode !== 'light');
|
|
326
|
+
const kids = [face([P(CORE.x0, CORE.y0), P(CORE.x1, CORE.y0), P(CORE.x1, CORE.y1), P(CORE.x0, CORE.y1)], CI.plaza, CI.plazaLine)];
|
|
327
|
+
Gov.CIVIC_SLOTS.slice().sort((p, q) => (p[1] + p[2]) - (q[1] + q[2])).forEach((sl) => {
|
|
328
|
+
const list = [];
|
|
329
|
+
Gov.isoInstitution(list, k, sl[0], sl[1] - 0.6, sl[2] - 0.6, {});
|
|
330
|
+
list.forEach((pa) => kids.push(node('path', { d: pa.d, fill: pa.f, stroke: pa.s, 'stroke-width': pa.w, 'stroke-dasharray': pa.dash, 'stroke-linejoin': 'round', class: 'hc-face' })));
|
|
331
|
+
});
|
|
332
|
+
return node('a', { href: Gov.STREET_HREF || '/street', class: 'hc-door hc-core' + quiet('core'), 'aria-label': 'The government district: nine buildings, one for each part of the government. Walk the street.', 'data-tip': 'Government', 'data-tip-body': 'The Cloud Bongos core: nine buildings, one for each part of the government. Click to walk the street.' }, kids);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The art museum and its garden, after the Cleveland Museum of Art: a long white
|
|
336
|
+
// marble block with a columned portico and wide steps facing the park, the glass
|
|
337
|
+
// atrium between two dark granite wings behind it, and in front the Fine Arts
|
|
338
|
+
// Garden — a fountain on the axis, the lagoon beyond, trees down both sides.
|
|
339
|
+
function museumNode() {
|
|
340
|
+
const g = [];
|
|
341
|
+
g.push(face([P(3.4, 15.0), P(12.6, 15.0), P(12.6, 19.2), P(3.4, 19.2)], I.grass, I.grassLine));
|
|
342
|
+
g.push(face([P(7.75, 14.9), P(8.25, 14.9), P(8.25, 19.2), P(7.75, 19.2)], I.path));
|
|
343
|
+
g.push(face([P(3.4, 15.9), P(12.6, 15.9), P(12.6, 16.25), P(3.4, 16.25)], I.path));
|
|
344
|
+
const lc = P(8, 17.9, 0);
|
|
345
|
+
g.push(node('ellipse', { cx: r1(lc[0]), cy: r1(lc[1]), rx: 75, ry: 16, fill: I.water, transform: `rotate(26.57 ${r1(lc[0])} ${r1(lc[1])})` }));
|
|
346
|
+
const fc = P(8, 16.07, 0);
|
|
347
|
+
g.push(node('ellipse', { cx: r1(fc[0]), cy: r1(fc[1]), rx: 11, ry: 5.5, fill: I.water }));
|
|
348
|
+
g.push(...box(4.6, 12.3, 11.4, 14.0, 0, 6, I.marble));
|
|
349
|
+
g.push(...box(5.6, 10.9, 10.4, 12.3, 0, 30, I.granite));
|
|
350
|
+
g.push(...box(6.2, 11.2, 9.8, 12.3, 30, 10, I.mglass));
|
|
351
|
+
g.push(...box(5.0, 12.5, 11.0, 13.6, 6, 34, I.marble));
|
|
352
|
+
for (let z = 14; z < 38; z += 9) g.push(face([P(5.25, 13.6, z), P(10.75, 13.6, z), P(10.75, 13.6, z + 0.8), P(5.25, 13.6, z + 0.8)], I.dark));
|
|
353
|
+
for (let wx = 5.45; wx < 10.7; wx += 0.62) if (wx < 7.1 || wx > 8.9) g.push(face([P(wx, 13.6, 16), P(wx + 0.22, 13.6, 16), P(wx + 0.22, 13.6, 30), P(wx, 13.6, 30)], I.dark));
|
|
354
|
+
g.push(...box(4.9, 12.4, 11.1, 13.7, 40, 3, I.marble));
|
|
355
|
+
for (let st = 0; st < 4; st++) g.push(...box(6.7 - st * 0.08, 13.6 + st * 0.3, 9.3 + st * 0.08, 13.9 + st * 0.3, 0, 6 - st * 1.5, I.marble));
|
|
356
|
+
g.push(...box(7.0, 13.6, 9.0, 14.05, 37, 6, I.marble));
|
|
357
|
+
for (let c = 0; c < 6; c++) {
|
|
358
|
+
const cx = 7.15 + c * 0.34, a = P(cx, 13.98, 6), b = P(cx, 13.98, 37);
|
|
359
|
+
g.push(line(a, b, I.col, 3.2), line([a[0] + 1.2, a[1]], [b[0] + 1.2, b[1]], I.dark, 0.8));
|
|
360
|
+
}
|
|
361
|
+
g.push(face([P(7.25, 13.62, 8), P(8.75, 13.62, 8), P(8.75, 13.62, 30), P(7.25, 13.62, 30)], I.dark));
|
|
362
|
+
[[3.9, 15.4], [4.6, 15.5], [11.4, 15.4], [12.1, 15.5], [3.8, 16.9], [12.2, 16.8], [4.2, 18.6], [11.8, 18.5], [5.1, 19.0], [10.9, 19.0], [3.9, 17.8], [12.1, 17.7]].forEach((tr, n) => {
|
|
363
|
+
const p = P(tr[0], tr[1], 0);
|
|
364
|
+
g.push(line(p, [p[0], p[1] - 9], I.dark, 1.6), circle(p[0], p[1] - 16, 8.5 + (n % 3) * 1.5, { fill: I.tree[n % 3] }));
|
|
365
|
+
});
|
|
366
|
+
return node('a', { href: '/', class: 'hc-door hc-museum' + quiet('museum'), 'data-craft-door': 'artistry', 'aria-label': 'The art museum, where the artists’ works hang. Go in.', 'data-tip': 'The art museum', 'data-tip-body': 'The art district: where the artists’ works hang, beside the park. Click to go in.' }, g);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- the ideator's sky ----
|
|
371
|
+
|
|
372
|
+
// The skyline: every building of the city as seen from the street, back to front.
|
|
373
|
+
// A box from (x0,y0) to (x1,y1) spans screen x P(x0,y1) to P(x1,y0) in citySvg's own
|
|
374
|
+
// frame (viewBoxOf's OX), so a silhouette stands exactly where its building does.
|
|
375
|
+
// Heights are the city's: a tower its plinth plus what it has built by week t, a civic
|
|
376
|
+
// building city-draw.js's ROOF at the core's scale, the museum its marble and atrium.
|
|
377
|
+
function skylineOf(m, t, gov) {
|
|
378
|
+
const OX = viewBoxOf(m).OX;
|
|
379
|
+
const sx = (x, y) => OX + (x - y) * TW / 2;
|
|
380
|
+
const sil = [];
|
|
381
|
+
m.areas.forEach((a) => {
|
|
382
|
+
const d = doneAt(a, t);
|
|
383
|
+
sil.push({ k: 'area', id: a.id, title: a.title, l: sx(a.x, a.y + FOOT), r: sx(a.x + FOOT, a.y), h: 4 + Math.max(2, (d.build + d.art) * m.px), depth: a.x + a.y + FOOT });
|
|
384
|
+
});
|
|
385
|
+
if (gov && Array.isArray(gov.CIVIC_SLOTS) && gov.ROOF) {
|
|
386
|
+
gov.CIVIC_SLOTS.forEach((s) => {
|
|
387
|
+
const x = s[1] - 0.6, y = s[2] - 0.6;
|
|
388
|
+
sil.push({ k: 'civic', id: s[0], l: sx(x + 0.3, y + 1.7), r: sx(x + 1.7, y + 0.3), h: num(gov.ROOF[s[0]]) * CIVIC_S, depth: x + y + 2, charter: s[0] === 'charter' });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
MUSEUM_SIL.forEach((b) => sil.push({ k: 'museum', id: b.portico ? 'museum' : 'atrium', l: sx(b.x0, b.y1), r: sx(b.x1, b.y0), h: b.h, depth: b.x1 + b.y1, portico: !!b.portico }));
|
|
392
|
+
return sil.sort((p, q) => p.depth - q.depth || p.l - q.l);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The ideas placed in the sky: a constellation per working area that has any (the
|
|
396
|
+
// most ideas first, up to the slots), the rest of the sky's ideas in the loose field.
|
|
397
|
+
function skyPlaces(m) {
|
|
398
|
+
const count = {};
|
|
399
|
+
m.ideas.forEach((x) => { if (x.area) count[x.area] = (count[x.area] || 0) + 1; });
|
|
400
|
+
const byId = new Map(m.areas.map((a) => [a.id, a]));
|
|
401
|
+
const groups = Object.keys(count).filter((id) => byId.has(id))
|
|
402
|
+
.sort((p, q) => count[q] - count[p] || p.localeCompare(q)).slice(0, SKY_SLOTS.length)
|
|
403
|
+
.map((id, n) => ({ area: byId.get(id), cx: SKY_SLOTS[n][0], cy: SKY_SLOTS[n][1] }));
|
|
404
|
+
const slot = new Map(groups.map((g) => [g.area.id, g]));
|
|
405
|
+
const spread = groups.length > 6 ? 0.75 : 1;
|
|
406
|
+
const at = new Map();
|
|
407
|
+
m.ideas.forEach((x) => {
|
|
408
|
+
const h = hash(x.id), a = (h % 1000) / 1000 * Math.PI * 2, f = (Math.floor(h / 1000) % 1000) / 1000;
|
|
409
|
+
const g = x.area ? slot.get(x.area) : null;
|
|
410
|
+
if (g) { const r = (20 + f * 56) * spread; at.set(x.id, { x: g.cx + Math.cos(a) * r * 1.4, y: g.cy + Math.sin(a) * r * 0.7, g, a }); }
|
|
411
|
+
else { const r = Math.sqrt(f); at.set(x.id, { x: LOOSE.cx + Math.cos(a) * r * LOOSE.rx * 0.9, y: LOOSE.cy + Math.sin(a) * r * LOOSE.ry * 0.8, g: null, a }); }
|
|
412
|
+
});
|
|
413
|
+
return { groups, at };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// How far along the version is by week t: what its areas have shipped over every task
|
|
417
|
+
// under them. Null when the server could not say how many tasks there are.
|
|
418
|
+
function versionBuilt(m, t) {
|
|
419
|
+
if (!m.version || !m.version.tasks) return null;
|
|
420
|
+
const done = m.areas.reduce((s, a) => { const d = doneAt(a, t); return s + d.build + d.art; }, 0);
|
|
421
|
+
return Math.max(0, Math.min(1, done / m.version.tasks));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The sky for week t as shapes (citySvg's contract: a tree of plain nodes, never markup).
|
|
425
|
+
function skySvg(m, t, opts) {
|
|
426
|
+
const o = opts || {};
|
|
427
|
+
const C = skyTheme(o.mode);
|
|
428
|
+
const Gov = o.gov || null;
|
|
429
|
+
const i = Math.max(0, Math.min(m.W - 1, Math.floor(t + 1e-6)));
|
|
430
|
+
const latest = t >= m.W - 1 - 1e-3;
|
|
431
|
+
const HOR = SKY.h;
|
|
432
|
+
const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
|
|
433
|
+
const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r: r1(r) }, attrs));
|
|
434
|
+
const label = (x, y, s) => node('text', { x: r1(x), y: r1(y), 'text-anchor': 'middle', fill: C.label, class: 'hc-sky__label' }, [], s);
|
|
435
|
+
const when = i === m.W - 1 ? 'this week' : 'that week';
|
|
436
|
+
const out = [node('defs', {}, [
|
|
437
|
+
node('radialGradient', { id: 'hc-sky-orb' }, [node('stop', { offset: 0, 'stop-color': C.glow }), node('stop', { offset: 1, 'stop-color': 'transparent' })]),
|
|
438
|
+
node('linearGradient', { id: 'hc-sky-tail', x1: 0, y1: 0, x2: 1, y2: 1 }, [node('stop', { offset: 0, 'stop-color': C.streak, 'stop-opacity': 0 }), node('stop', { offset: 1, 'stop-color': C.streak })]),
|
|
439
|
+
])];
|
|
440
|
+
|
|
441
|
+
// the dust: the same faint field every week
|
|
442
|
+
for (let n = 0; n < 90; n++) { const h = hash('dust' + n); out.push(circle((h % 1000) / 1000 * SKY.w, (Math.floor(h / 1000) % 1000) / 1000 * (HOR - 170), (h % 7) ? 0.7 : 1.1, { fill: C.star, opacity: 0.18 })); }
|
|
443
|
+
|
|
444
|
+
// the moon: the version being built, its phase how far along
|
|
445
|
+
const built = versionBuilt(m, t);
|
|
446
|
+
if (m.version) {
|
|
447
|
+
const mk = [circle(MOON.x, MOON.y, MOON.r + 26, { fill: 'url(#hc-sky-orb)' }), circle(MOON.x, MOON.y, MOON.r, { fill: C.moon })];
|
|
448
|
+
if (built != null) mk.push(circle(MOON.x + MOON.r * 2 * built, MOON.y - 2, MOON.r, { fill: C.shade, opacity: 0.94 }));
|
|
449
|
+
out.push(node('g', { class: 'hc-moon', 'data-tip': `${m.version.name} — the version being built`, 'data-tip-body': built == null ? 'Its building has begun.' : `${Math.round(built * 100)}% built ${when}` }, mk));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// THE SKYLINE IS THE CITY: each silhouette's fill then its outline, back to front, so a
|
|
453
|
+
// nearer building covers the outline of the one behind it.
|
|
454
|
+
const sil = skylineOf(m, t, Gov);
|
|
455
|
+
const shape = (s, y) => {
|
|
456
|
+
if (s.charter) {
|
|
457
|
+
const w = s.r - s.l, h = HOR - y, at = (f) => r1(HOR - h * f);
|
|
458
|
+
return `M${r1(s.l)} ${HOR}V${at(0.33)}H${r1(s.l + w * 0.1)}V${at(0.58)}H${r1(s.l + w * 0.22)}V${at(0.8)}H${r1(s.l + w * 0.35)}V${r1(y)}H${r1(s.r - w * 0.35)}V${at(0.8)}H${r1(s.r - w * 0.22)}V${at(0.58)}H${r1(s.r - w * 0.1)}V${at(0.33)}H${r1(s.r)}V${HOR}Z`;
|
|
459
|
+
}
|
|
460
|
+
if (s.portico) { const pm = (s.l + s.r) / 2; return `M${r1(s.l)} ${HOR}V${r1(y)}H${r1(pm - 28)}V${r1(y - 4)}H${r1(pm + 28)}V${r1(y)}H${r1(s.r)}V${HOR}Z`; }
|
|
461
|
+
return `M${r1(s.l)} ${HOR}V${r1(y)}H${r1(s.r)}V${HOR}Z`;
|
|
462
|
+
};
|
|
463
|
+
const maxD = Math.max(1, ...sil.map((s) => s.depth));
|
|
464
|
+
const fills = [], hits = [], tops = {};
|
|
465
|
+
sil.forEach((s) => {
|
|
466
|
+
const y = HOR - s.h, d = shape(s, y);
|
|
467
|
+
const fill = s.k === 'area' ? (s.depth / maxD < 0.5 ? C.far : C.near) : C.civic;
|
|
468
|
+
fills.push(node('path', { d, fill, class: 'hc-sil' }));
|
|
469
|
+
fills.push(node('path', { d, fill: 'none', stroke: C.outline, 'stroke-width': 1.4, 'stroke-linejoin': 'round', class: `hc-ol hc-ol-${s.k}` }));
|
|
470
|
+
if (s.k === 'civic' && (s.id === 'charter' || s.id === 'automation' || s.id === 'release')) fills.push(circle((s.l + s.r) / 2, y - 4, 2.4, { fill: C.beacon }));
|
|
471
|
+
if (s.k === 'area') tops[s.id] = { s, y };
|
|
472
|
+
// the hit floor: a low silhouette still offers a band tall enough to point at
|
|
473
|
+
hits.push({ s, d: shape(s, Math.min(y, HOR - 30)) });
|
|
474
|
+
});
|
|
475
|
+
out.push(node('g', { class: 'hc-skyline' }, fills));
|
|
476
|
+
|
|
477
|
+
// a window lights in a working area for every one of its ideas that became work
|
|
478
|
+
const wins = [];
|
|
479
|
+
Object.values(tops).forEach(({ s, y }) => {
|
|
480
|
+
const lit = m.ideas.filter((x) => x.goal === s.id && x.became != null && x.became <= i).length;
|
|
481
|
+
const cols = Math.max(1, Math.floor((s.r - s.l - 8) / 9)), rows = Math.max(0, Math.floor((s.h - 14) / 12));
|
|
482
|
+
for (let q = 0; q < Math.min(lit, cols * rows); q++) wins.push(node('rect', { x: r1(s.l + 6 + (q % cols) * 9), y: r1(y + 8 + Math.floor(q / cols) * 12), width: 4.5, height: 6.5, fill: C.win }));
|
|
483
|
+
});
|
|
484
|
+
if (wins.length) out.push(node('g', { class: 'hc-wins', 'aria-hidden': 'true' }, wins));
|
|
485
|
+
|
|
486
|
+
// the three doors over the skyline: one link per silhouette, back to front like the
|
|
487
|
+
// fills, so the nearest building takes the pointer; hovering any of a group outlines
|
|
488
|
+
// the whole group (home-city.css). One stop per group on the keyboard.
|
|
489
|
+
const DOORS = {
|
|
490
|
+
area: { href: '/goals', tip: 'Building', body: 'Every goal building in the city. Click to open the goals.', aria: 'The goal buildings. Open the goals.' },
|
|
491
|
+
civic: { href: (Gov && Gov.STREET_HREF) || '/builders/street', tip: 'Government', body: 'The Cloud Bongos core. Click to walk the street.', aria: 'The government district. Walk the street.' },
|
|
492
|
+
museum: { href: '/', door: 'artistry', tip: 'Art', body: 'The art museum. Click to go into the gallery.', aria: 'The art museum. Go into the gallery.' },
|
|
493
|
+
};
|
|
494
|
+
const first = {};
|
|
495
|
+
out.push(node('g', { class: 'hc-hits' }, hits.map(({ s, d }) => {
|
|
496
|
+
const D = DOORS[s.k], lead = !first[s.k];
|
|
497
|
+
first[s.k] = true;
|
|
498
|
+
const attrs = { href: D.href, class: 'hc-hit', 'data-group': s.k, 'data-tip': D.tip, 'data-tip-body': D.body };
|
|
499
|
+
if (D.door) attrs['data-craft-door'] = D.door;
|
|
500
|
+
if (lead) attrs['aria-label'] = D.aria; else { attrs.tabindex = '-1'; attrs['aria-hidden'] = 'true'; }
|
|
501
|
+
return node('a', attrs, [node('path', { d, fill: 'transparent' })]);
|
|
502
|
+
})));
|
|
503
|
+
|
|
504
|
+
// the ideas: what was filed by week t and has not yet fallen into its tower
|
|
505
|
+
const { groups, at } = skyPlaces(m);
|
|
506
|
+
const live = m.ideas.filter((x) => x.week <= i && !(x.became != null && x.became < i));
|
|
507
|
+
groups.forEach((g) => {
|
|
508
|
+
const stars = live.filter((x) => at.get(x.id).g === g).sort((p, q) => at.get(p.id).a - at.get(q.id).a);
|
|
509
|
+
if (!stars.length) return;
|
|
510
|
+
const kids = [];
|
|
511
|
+
for (let q = 1; q < stars.length; q++) { const a = at.get(stars[q - 1].id), b = at.get(stars[q].id); kids.push(node('line', { x1: r1(a.x), y1: r1(a.y), x2: r1(b.x), y2: r1(b.y), stroke: C.line, 'stroke-width': 0.9 })); }
|
|
512
|
+
const top = Math.min(...stars.map((x) => at.get(x.id).y));
|
|
513
|
+
const name = g.area.title.length > 30 ? g.area.title.slice(0, 29) + '…' : g.area.title;
|
|
514
|
+
kids.push(label(g.cx, Math.max(14, Math.min(top, g.cy - 30) - 12), name.toUpperCase()));
|
|
515
|
+
out.push(node('g', { class: 'hc-const', 'data-tip': g.area.title, 'data-tip-body': `${stars.length} idea${stars.length === 1 ? '' : 's'} in this constellation` }, kids));
|
|
516
|
+
});
|
|
517
|
+
const loose = live.filter((x) => !at.get(x.id).g);
|
|
518
|
+
if (loose.length) {
|
|
519
|
+
out.push(node('g', { class: 'hc-loose', 'data-tip': 'Not placed yet', 'data-tip-body': `${loose.length} idea${loose.length === 1 ? '' : 's'} no working area holds yet` }, [
|
|
520
|
+
node('ellipse', { cx: LOOSE.cx, cy: LOOSE.cy, rx: LOOSE.rx, ry: LOOSE.ry, fill: 'none', stroke: C.field, 'stroke-dasharray': '2 6' }),
|
|
521
|
+
label(LOOSE.cx, LOOSE.cy - LOOSE.ry - 10, 'NOT PLACED YET'),
|
|
522
|
+
]));
|
|
523
|
+
}
|
|
524
|
+
// a falling star: an idea that became work this week drops into its area's tower
|
|
525
|
+
m.ideas.forEach((x) => {
|
|
526
|
+
if (x.became !== i || !tops[x.goal]) return;
|
|
527
|
+
const p = at.get(x.id), tw = tops[x.goal], tx = tw.s.l + 14, ty = tw.y - 4;
|
|
528
|
+
out.push(node('g', { class: 'hc-fall', 'data-tip': x.title, 'data-tip-body': `became work ${when} · ${tw.s.title}` }, [
|
|
529
|
+
node('path', { d: `M${r1(p.x)} ${r1(p.y)}Q${r1((p.x + tx) / 2 + 40)} ${r1(p.y + 20)} ${r1(tx)} ${r1(ty)}`, fill: 'none', stroke: 'url(#hc-sky-tail)', 'stroke-width': 1.6, 'stroke-dasharray': '3 4' }),
|
|
530
|
+
circle(tx, ty, 3, { fill: C.streak }),
|
|
531
|
+
]));
|
|
532
|
+
});
|
|
533
|
+
let waiting = 0;
|
|
534
|
+
const areaName = new Map(m.areas.map((a) => [a.id, a.title]));
|
|
535
|
+
live.forEach((x) => {
|
|
536
|
+
const p = at.get(x.id), fresh = x.week === i, size = 1.6 + Math.min(12, x.votes) * 0.32, ring = latest && x.waits;
|
|
537
|
+
const who = x.by && m.people[x.by] ? m.people[x.by].name : 'Someone';
|
|
538
|
+
const where = p.g ? areaName.get(x.area) : 'not placed yet';
|
|
539
|
+
const kids = [
|
|
540
|
+
circle(p.x, p.y, size * 4, Object.assign({ fill: 'url(#hc-sky-orb)' }, fresh ? { class: 'hc-twinkle' } : {})),
|
|
541
|
+
circle(p.x, p.y, size, { fill: C.star, opacity: fresh ? 1 : r1(0.55 + Math.min(0.4, x.votes / 20)) }),
|
|
542
|
+
];
|
|
543
|
+
if (ring) { waiting++; kids.push(circle(p.x, p.y, size + 5, { fill: 'none', stroke: 'var(--accent)', 'stroke-width': 1.6, class: 'hc-ring' })); }
|
|
544
|
+
kids.push(circle(p.x, p.y, 12, { fill: 'transparent' }));
|
|
545
|
+
out.push(node('g', { class: 'hc-star', 'data-tip': x.title, 'data-tip-body': `${who} · ${x.votes} vote${x.votes === 1 ? '' : 's'} · ${where}${ring ? ' · waits on you' : ''}` }, kids));
|
|
546
|
+
});
|
|
547
|
+
if (waiting) {
|
|
548
|
+
const bx = LOOSE.cx + LOOSE.rx - 6, by = LOOSE.cy - LOOSE.ry + 8;
|
|
549
|
+
out.push(node('g', { class: 'hc-wait', 'data-tip': 'Waiting on you', 'data-tip-body': `${waiting} new idea${waiting === 1 ? '' : 's'} to place or vote on — the ringed stars` }, [
|
|
550
|
+
circle(bx, by, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
|
|
551
|
+
node('text', { x: r1(bx), y: r1(by + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], waiting),
|
|
552
|
+
]));
|
|
553
|
+
}
|
|
554
|
+
return { viewBox: `0 0 ${SKY.w} ${SKY.h}`, nodes: out };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A face over a crane: the builder's avatar clipped to a circle over their
|
|
558
|
+
// initial (so a picture that fails to load still leaves a face), or the robot
|
|
559
|
+
// glyph when an agent is building the site.
|
|
560
|
+
function avatarNode(p, who, agent, a, I, n, node, line, circle) {
|
|
561
|
+
const x = p[0], y = p[1], id = `hc-av-${n}`;
|
|
562
|
+
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 })];
|
|
563
|
+
if (agent) {
|
|
564
|
+
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 }));
|
|
565
|
+
g.push(circle(x - 3, y + 0.5, 1.5, { fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { fill: I.avInk }));
|
|
566
|
+
} else {
|
|
567
|
+
g.push(node('text', { x: r1(x), y: r1(y + 5), 'text-anchor': 'middle', fill: I.avInk, class: 'hc-av__i' }, [], initial(who.name)));
|
|
568
|
+
if (who.avatar && /^https:\/\//.test(who.avatar)) {
|
|
569
|
+
g.push(node('clipPath', { id }, [circle(x, y, 14.5, {})]));
|
|
570
|
+
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' }));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
|
|
577
|
+
// a stable spread for an idea's place in the sky (FNV-1a over its id)
|
|
578
|
+
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); }
|
|
579
|
+
|
|
580
|
+
const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS, RING, CORE, skySvg, skylineOf, skyPlaces, versionBuilt, skyTheme, SKY };
|
|
581
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
582
|
+
if (root) root.OTBHomeCityDraw = api;
|
|
583
|
+
})(typeof window !== 'undefined' ? window : null);
|