@bongos/core 1.24.30 → 1.24.32

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.
Files changed (101) hide show
  1. package/.bongos-core.json +153 -103
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +139 -1
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +192 -187
  10. package/docs/copy-registry.json +530 -458
  11. package/docs/module-api-changelog.md +4 -2
  12. package/docs/page-inventory.json +15 -35
  13. package/docs/page-readings.json +228 -265
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/hall-render.js +62 -86
  35. package/modules/hall-ui/public/home-city-draw.js +360 -52
  36. package/modules/hall-ui/public/home-city.css +47 -11
  37. package/modules/hall-ui/public/home-city.js +170 -40
  38. package/modules/hall-ui/public/home-gallery.css +129 -0
  39. package/modules/hall-ui/public/home-gallery.js +349 -0
  40. package/modules/hall-ui/public/home-studio.css +72 -0
  41. package/modules/hall-ui/public/home-studio.js +203 -0
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +4 -1
  45. package/modules/hall-ui/public/market-watch-item.html +1 -1
  46. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  47. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  48. package/modules/hall-ui/public/market-watch.html +141 -54
  49. package/modules/hall-ui/public/market-watch.js +54 -0
  50. package/modules/hall-ui/public/market-watch.states.json +2 -1
  51. package/modules/hall-ui/public/modules.html +1 -1
  52. package/modules/hall-ui/public/my-agents.html +1 -1
  53. package/modules/hall-ui/public/primer.html +1 -1
  54. package/modules/hall-ui/public/profile.html +1 -1
  55. package/modules/hall-ui/public/project-settings.html +1 -1
  56. package/modules/hall-ui/public/ranks.html +1 -1
  57. package/modules/hall-ui/public/roadmap.html +1 -1
  58. package/modules/hall-ui/public/roster.html +1 -1
  59. package/modules/hall-ui/public/sessions.html +1 -1
  60. package/modules/hall-ui/public/settings.html +1 -1
  61. package/modules/hall-ui/public/shell.js +2 -3
  62. package/modules/hall-ui/public/street.html +1 -1
  63. package/modules/hall-ui/public/studio.html +9 -1
  64. package/modules/hall-ui/public/style.css +74 -27
  65. package/modules/hall-ui/public/task.html +1 -1
  66. package/modules/hall-ui/public/thinking.html +1 -1
  67. package/modules/hall-ui/public/tweak-editor.html +1 -1
  68. package/modules/hall-ui/public/watch.html +1 -1
  69. package/modules/hall-ui/public/work.html +1 -1
  70. package/modules/hall-ui/records/home-city.md +133 -0
  71. package/modules/hall-ui/records/loader.md +1 -1
  72. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  73. package/modules/hall-ui/records/market-watch.md +3 -1
  74. package/modules/lifecycle/home-city.js +58 -10
  75. package/modules/lifecycle/home-gallery.js +320 -0
  76. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  77. package/modules/lifecycle/module.json +1 -0
  78. package/modules/lifecycle/routes/home-gallery.js +37 -0
  79. package/modules/market-watch/routes/settings.js +1 -1
  80. package/package-lock.json +2 -2
  81. package/package.json +1 -1
  82. package/release-notes.json +35 -1
  83. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  84. package/scripts/hall-preview/README.md +1 -0
  85. package/scripts/hall-preview/server.js +10 -0
  86. package/src/bongos/serve-internal.js +17 -3
  87. package/src/module-api.js +1 -1
  88. package/tests/hall_builders_page_pass.mjs +18 -27
  89. package/tests/hall_loading.mjs +1 -1
  90. package/tests/hall_nav.mjs +11 -4
  91. package/tests/hall_page_gate_map.mjs +7 -4
  92. package/tests/home_city.mjs +200 -5
  93. package/tests/home_city_widget.mjs +187 -27
  94. package/tests/home_gallery.mjs +255 -0
  95. package/tests/home_gallery_widget.mjs +220 -0
  96. package/tests/home_studio.mjs +169 -0
  97. package/tests/home_studio_widget.mjs +157 -0
  98. package/tests/market_watch_pages.mjs +162 -0
  99. package/tests/nav_permission_atoms.mjs +2 -2
  100. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  101. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -1,4 +1,5 @@
1
- // modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054).
1
+ // modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054;
2
+ // v16, task 1005058).
2
3
  //
3
4
  // WHAT THIS IS. The drawing half of Home's city (the owner's v14 mock,
4
5
  // docs/design/mocks/hall/home/v14.html): one isometric tower per working area,
@@ -9,6 +10,29 @@
9
10
  // home-city.js owns the page half (the read, the slider, the ladder's DOM,
10
11
  // hover); this file only turns the weekly summary into shapes and rows.
11
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
+ //
12
36
  // PURE. No DOM, no fetch, no clock: the same summary and week give the same
13
37
  // shapes, so tests run it in node (the roadmap-timeline.js precedent). The scene
14
38
  // comes back as a tree of plain shape objects, never markup (see citySvg).
@@ -21,18 +45,42 @@
21
45
  'use strict';
22
46
 
23
47
  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
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
30
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 }];
31
71
 
32
72
  const r1 = (n) => Math.round(n * 10) / 10;
33
73
  const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
34
74
  const sum = (a) => (Array.isArray(a) ? a.reduce((s, v) => s + num(v), 0) : 0);
35
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
+
36
84
  function theme(mode) {
37
85
  if (mode === 'light') {
38
86
  return {
@@ -44,6 +92,10 @@
44
92
  ground: 'rgba(23,24,26,0.035)', groundLine: 'rgba(23,24,26,0.08)',
45
93
  orb: '#d98a4e', orbGlow: 'rgba(217,138,78,0.30)', ideaLine: 'rgba(217,138,78,0.55)',
46
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)',
47
99
  };
48
100
  }
49
101
  return {
@@ -55,6 +107,10 @@
55
107
  ground: 'rgba(255,255,255,0.018)', groundLine: 'rgba(255,255,255,0.05)',
56
108
  orb: '#ffe2c2', orbGlow: 'rgba(255,214,170,0.35)', ideaLine: 'rgba(255,214,170,0.45)',
57
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)',
58
114
  };
59
115
  }
60
116
 
@@ -75,35 +131,42 @@
75
131
  all.forEach((a) => { a.total = a.baseBuild + a.baseArt + sum(a.build) + sum(a.art); });
76
132
  all.sort((p, q) => q.total - p.total || p.id.localeCompare(q.id));
77
133
  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
- });
134
+ areas.forEach((a, n) => { a.x = RING[n][0]; a.y = RING[n][1]; });
84
135
  const tallest = Math.max(1, ...areas.map((a) => a.total));
85
136
  const people = {};
86
137
  (Array.isArray(d.people) ? d.people : []).forEach((p) => {
87
138
  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') };
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') };
89
140
  });
90
141
  const now = d.now || {};
91
142
  const sites = {};
92
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 }; });
93
144
  const waiting = {};
94
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); });
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 }));
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;
96
155
  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,
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,
100
159
  };
101
160
  }
102
161
 
103
- // What an area has built by the end of week i (the week's own ships included).
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.
104
165
  function doneAt(a, i) {
105
166
  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]; }
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; }
107
170
  return { build, art };
108
171
  }
109
172
 
@@ -124,10 +187,14 @@
124
187
 
125
188
  // The ladder for week i: builders by what they did that week, the top seven,
126
189
  // and the viewer appended when they fall below (a phone leaderboard's "you").
127
- function ladderFor(m, i, viewerId) {
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) {
128
193
  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 };
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) };
131
198
  }).filter((r) => r.total > 0 || r.me)
132
199
  .sort((p, q) => q.total - p.total || p.name.localeCompare(q.name));
133
200
  const top = rows.slice(0, LADDER_ROWS);
@@ -139,7 +206,7 @@
139
206
  // The scene's viewBox for a model: the ground and the tallest tower plus room
140
207
  // for its crane and face, trimmed so no empty sky stands above it.
141
208
  function viewBoxOf(m) {
142
- const OY = TOWER_PX + 110, h = OY + (m.width + m.depth) * TH / 2 + 24;
209
+ const OY = SKY_H + TOWER_PX + 100, h = OY + (m.width + m.depth) * TH / 2 + 16;
143
210
  return { w: 1000, h: Math.round(h), OX: 500 - (m.width - m.depth) * TW / 4, OY };
144
211
  }
145
212
 
@@ -148,36 +215,57 @@
148
215
  // setAttribute (city-draw.js's split exactly — "city.js only turns the lists into
149
216
  // SVG nodes"). So no ledger string is ever parsed as markup: a title is an
150
217
  // attribute value or a text node, which is also why this file needs no escaper.
151
- function citySvg(m, i, opts) {
218
+ function citySvg(m, t, opts) {
152
219
  const o = opts || {};
153
220
  const I = theme(o.mode);
221
+ const Gov = o.gov || null;
154
222
  const vb = viewBoxOf(m);
155
- const latest = i === m.W - 1;
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;
156
225
  const P = (x, y, z) => [vb.OX + (x - y) * TW / 2, vb.OY + (x + y) * TH / 2 - (z || 0)];
157
226
  const pts = (a) => 'M' + a.map((p) => r1(p[0]) + ' ' + r1(p[1])).join('L') + 'Z';
158
227
  const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
159
228
  const face = (a, fill, stroke) => node('path', { d: pts(a), fill, stroke: stroke || 'none', 'stroke-width': 1, 'stroke-linejoin': 'round', class: 'hc-face' });
160
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 } : {}));
161
230
  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) => {
231
+ const box = (x0, y0, x1, y1, z0, h, tn) => {
163
232
  const z1 = z0 + h;
164
233
  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),
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),
168
237
  ];
169
238
  };
239
+ const quiet = (key) => (o.focus === key ? '' : ' hc-quiet');
170
240
 
171
241
  const out = [
172
242
  node('defs', {}, [node('radialGradient', { id: 'hc-orb' }, [node('stop', { offset: 0, 'stop-color': I.orbGlow }), node('stop', { offset: 1, 'stop-color': 'transparent' })])]),
173
243
  face([P(0, 0), P(m.width, 0), P(m.width, m.depth), P(0, m.depth)], I.ground, I.groundLine),
174
244
  ];
175
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
+
176
262
  const sites = sitesFor(m, i);
177
263
  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;
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;
181
269
  const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
182
270
  const wait = latest ? (m.waiting[a.id] || 0) : 0;
183
271
  const g = [];
@@ -215,24 +303,9 @@
215
303
  if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
216
304
  }
217
305
  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));
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));
236
309
  });
237
310
 
238
311
  over.forEach((v, n) => {
@@ -244,6 +317,241 @@
244
317
  ]));
245
318
  });
246
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 };
247
555
  }
248
556
 
249
557
  // A face over a crane: the builder's avatar clipped to a circle over their
@@ -269,7 +577,7 @@
269
577
  // a stable spread for an idea's place in the sky (FNV-1a over its id)
270
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); }
271
579
 
272
- const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS };
580
+ const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS, RING, CORE, skySvg, skylineOf, skyPlaces, versionBuilt, skyTheme, SKY };
273
581
  if (typeof module === 'object' && module.exports) module.exports = api;
274
582
  if (root) root.OTBHomeCityDraw = api;
275
583
  })(typeof window !== 'undefined' ? window : null);