RDS777 commited on
Commit
c40d8dd
·
verified ·
1 Parent(s): df66396

Upload 26 files

Browse files
mouh/learning/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "version": "v4.5.1",
3
  "files": {
4
  "app.py": "36ff9411bd29395d",
5
  "requirements.txt": "613d00e516f8f4b8",
@@ -25,7 +25,7 @@
25
  "engine/__init__.py": "9ed43b8c6346ad0e",
26
  "engine/trainer.py": "778e08d76a7731e3",
27
  "mouh/webui.py": "de56bcb7b5a926c5",
28
- "mouh/world.py": "eb7541962969ba76",
29
  "mouh/lab/__init__.py": "374f8a6b3c61de0a",
30
  "mouh/lab/common.py": "3e81863585cd64a8",
31
  "mouh/lab/algorithms.py": "603da6df0953b6d0",
@@ -37,7 +37,7 @@
37
  "mouh/lab/api.py": "e3b2d3894f81a5de",
38
  "mouh/lab/examples.py": "e1218f95b6f3c220",
39
  "unique_ext/README.md": "0f0be41551e6bacb",
40
- "unique_ext/__init__.py": "a8f8476d8d99df3d",
41
  "unique_ext/backends/__init__.py": "e3b0c44298fc1c14",
42
  "unique_ext/backends/hf.py": "506d29e81c127e1e",
43
  "unique_ext/contracts.py": "4ce5632e921dfcdc",
@@ -63,8 +63,8 @@
63
  "web/symbioza/index.html": "af4f860577542cea",
64
  "web/symbioza/static/css/hub.css": "18b95106f5f2022a",
65
  "web/symbioza/static/css/hud.css": "87ba7a20761a9d6a",
66
- "web/symbioza/static/js/app.js": "b198351f459479cb",
67
- "web/symbioza/static/js/flat.js": "5741ea89873ccf16",
68
  "web/symbioza/static/js/hub.js": "dc1ca2ac931a0c2e",
69
  "web/symbioza/static/js/shaders.js": "0e070676691c054c",
70
  "web/symbioza/static/vendor/three/LICENSE.txt": "8b378ebe60e2fe50",
 
1
  {
2
+ "version": "v4.5.3",
3
  "files": {
4
  "app.py": "36ff9411bd29395d",
5
  "requirements.txt": "613d00e516f8f4b8",
 
25
  "engine/__init__.py": "9ed43b8c6346ad0e",
26
  "engine/trainer.py": "778e08d76a7731e3",
27
  "mouh/webui.py": "de56bcb7b5a926c5",
28
+ "mouh/world.py": "11dfe4d90d48b2d7",
29
  "mouh/lab/__init__.py": "374f8a6b3c61de0a",
30
  "mouh/lab/common.py": "3e81863585cd64a8",
31
  "mouh/lab/algorithms.py": "603da6df0953b6d0",
 
37
  "mouh/lab/api.py": "e3b2d3894f81a5de",
38
  "mouh/lab/examples.py": "e1218f95b6f3c220",
39
  "unique_ext/README.md": "0f0be41551e6bacb",
40
+ "unique_ext/__init__.py": "7aae5378f906d108",
41
  "unique_ext/backends/__init__.py": "e3b0c44298fc1c14",
42
  "unique_ext/backends/hf.py": "506d29e81c127e1e",
43
  "unique_ext/contracts.py": "4ce5632e921dfcdc",
 
63
  "web/symbioza/index.html": "af4f860577542cea",
64
  "web/symbioza/static/css/hub.css": "18b95106f5f2022a",
65
  "web/symbioza/static/css/hud.css": "87ba7a20761a9d6a",
66
+ "web/symbioza/static/js/app.js": "c977fdb2efe9c392",
67
+ "web/symbioza/static/js/flat.js": "62503f1c83fc2151",
68
  "web/symbioza/static/js/hub.js": "dc1ca2ac931a0c2e",
69
  "web/symbioza/static/js/shaders.js": "0e070676691c054c",
70
  "web/symbioza/static/vendor/three/LICENSE.txt": "8b378ebe60e2fe50",
mouh/world.py CHANGED
@@ -305,11 +305,38 @@ def _compact(path: Path) -> None:
305
 
306
 
307
  def objects(world_dir: Path, limit: int = 60) -> list[dict]:
308
- """Index du monde, le plus récent d'abord : un objet par emplacement, **sans** son rendu (léger)."""
 
 
 
 
 
309
  with _LOCK:
310
  latest = list(_latest_by_slot(Path(world_dir) / OBJECTS).values())
311
  latest.sort(key=lambda r: r.get("at", ""), reverse=True)
312
- return [{k: v for k, v in r.items() if k != "render"} for r in latest[:max(1, limit)]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
 
315
  def object_render(world_dir: Path, object_id: str) -> dict | None:
 
305
 
306
 
307
  def objects(world_dir: Path, limit: int = 60) -> list[dict]:
308
+ """Index du monde, un objet par emplacement, **sans** son rendu (léger), le plus récent d'abord.
309
+
310
+ Quand le monde dépasse ``limit``, chaque secteur garde sa part (v4.5.2) et les artefacts simulés passent avant les
311
+ structures de code : une reconstruction de 60 sessions de code ne doit pas chasser de l'arène le drone ou le pont
312
+ que l'essaim vient de valider.
313
+ """
314
  with _LOCK:
315
  latest = list(_latest_by_slot(Path(world_dir) / OBJECTS).values())
316
  latest.sort(key=lambda r: r.get("at", ""), reverse=True)
317
+ limit = max(1, limit)
318
+ if len(latest) > limit:
319
+ quota = max(1, limit // len(SECTORS))
320
+ chosen: list[dict] = []
321
+ taken: set[int] = set()
322
+ per: dict[str, int] = {}
323
+ for artefacts_first in (True, False): # 1) artefacts par secteur, 2) structures par secteur
324
+ for i, r in enumerate(latest):
325
+ d = r.get("domain", "algorithmique")
326
+ if i in taken or (r.get("matter") == "artefact") != artefacts_first or per.get(d, 0) >= quota:
327
+ continue
328
+ chosen.append(r)
329
+ taken.add(i)
330
+ per[d] = per.get(d, 0) + 1
331
+ for i, r in enumerate(latest): # 3) le reste de la place, au plus récent
332
+ if len(chosen) >= limit:
333
+ break
334
+ if i not in taken:
335
+ chosen.append(r)
336
+ taken.add(i)
337
+ order = {id(r): i for i, r in enumerate(latest)}
338
+ latest = sorted(chosen[:limit], key=lambda r: order[id(r)])
339
+ return [{k: v for k, v in r.items() if k != "render"} for r in latest]
340
 
341
 
342
  def object_render(world_dir: Path, object_id: str) -> dict | None:
tests/test_frontend.py CHANGED
@@ -209,3 +209,10 @@ def test_cache_policy_revalidates_the_page_and_spares_the_api():
209
  assert cache_policy("/symbioza/static/css/hud.css") == "no-cache"
210
  assert cache_policy("/static/vendor/three/build/three.module.js").startswith("public, max-age=")
211
  assert cache_policy("/api/ui/meta") is None and cache_policy("/gradio/") is None
 
 
 
 
 
 
 
 
209
  assert cache_policy("/symbioza/static/css/hud.css") == "no-cache"
210
  assert cache_policy("/static/vendor/three/build/three.module.js").startswith("public, max-age=")
211
  assert cache_policy("/api/ui/meta") is None and cache_policy("/gradio/") is None
212
+
213
+
214
+ def test_the_flat_view_is_frugal_on_old_machines():
215
+ """v4.5.3 : tracés groupés, budget de segments par image, aucune image tracée quand rien ne bouge."""
216
+ flat = FLAT.read_text(encoding="utf-8")
217
+ assert "new Path2D()" in flat and "EDGE_BUDGET" in flat and "FRAME_MS" in flat
218
+ assert "(!dirty && !animated())" in flat
tests/test_world.py CHANGED
@@ -207,3 +207,16 @@ def test_rebuild_is_admin_only_and_rebenches_old_sessions(unified):
207
  r = c.post("/api/ui/jobs/world", json={"limit": 10}, headers={"x-admin-key": "secret"})
208
  j = wait(c, r.json()["job"])
209
  assert j["state"] == "done" and "dont 1 en rupture" in j["result"]["message"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  r = c.post("/api/ui/jobs/world", json={"limit": 10}, headers={"x-admin-key": "secret"})
208
  j = wait(c, r.json()["job"])
209
  assert j["state"] == "done" and "dont 1 en rupture" in j["result"]["message"]
210
+
211
+
212
+ def test_a_flood_of_code_structures_never_pushes_validated_artifacts_out_of_the_arena(tmp_path):
213
+ """Régression v4.5.1 : 59 sessions de code reconstruites après le drone le chassaient des 40 objets affichés."""
214
+ report = lab.evaluate({"solution.py": TRUSS["candidate"]}, lab_tests(TRUSS))
215
+ world.deposit(tmp_path, instruction="pont", files={}, task_hash="p" * 64, certified=True, report=report)
216
+ time.sleep(1.1) # les structures arrivent après, comme une reconstruction
217
+ for i in range(59):
218
+ world.deposit(tmp_path, instruction=f"code {i}", files={"solution.py": f"def f{i}(x):\n return x + {i}\n"},
219
+ task_hash=f"s{i:03d}" + "x" * 60, certified=True, report=None)
220
+ shown = world.objects(tmp_path, limit=40)
221
+ assert len(shown) == 40 and any(o["domain"] == "mecanique" for o in shown)
222
+ assert [o["at"] for o in shown] == sorted((o["at"] for o in shown), reverse=True)
unique_ext/__init__.py CHANGED
@@ -13,7 +13,7 @@ from __future__ import annotations
13
  import os
14
 
15
  API_VERSION = 1 # contrat entre le job d'entraînement et ce paquet : le job refuse une autre valeur
16
- __version__ = "4.5.1"
17
 
18
 
19
  def enabled(name: str) -> bool:
 
13
  import os
14
 
15
  API_VERSION = 1 # contrat entre le job d'entraînement et ce paquet : le job refuse une autre valeur
16
+ __version__ = "4.5.3"
17
 
18
 
19
  def enabled(name: str) -> bool:
web/symbioza/static/js/app.js CHANGED
@@ -1104,6 +1104,11 @@ async function poll() {
1104
  const shown = flat.update(lastRecords, building);
1105
  setText('m-shown', String(shown));
1106
  body.dataset.items = String(shown);
 
 
 
 
 
1107
  setSource(shown ? 'live' : 'demo');
1108
  } else {
1109
  setSource('off');
@@ -1159,6 +1164,7 @@ function toggleBloom() {
1159
  function toggleRotate() {
1160
  state.rotate = !state.rotate;
1161
  setPressed('btn-rotate', state.rotate);
 
1162
  }
1163
 
1164
  function onKey(e) {
@@ -1274,6 +1280,7 @@ if (!ctx) {
1274
  showGlMessage(glError);
1275
  // Repli : le même monde en 2D isométrique. Un clic sur un objet ouvre l'inspecteur, comme dans la 3D.
1276
  flat = createFlat($('scene'), {
 
1277
  area: () => {
1278
  const w = window.innerWidth;
1279
  const h = window.innerHeight;
 
1104
  const shown = flat.update(lastRecords, building);
1105
  setText('m-shown', String(shown));
1106
  body.dataset.items = String(shown);
1107
+ // Jauges en vue en plan : le coût réel du dessin 2D sur cette machine (les calculs, eux, sont faits sur le Space).
1108
+ const st = flat.stats();
1109
+ setText('m-fps', st.animated ? st.fps.toFixed(0) : 'repos');
1110
+ setText('m-frame', `${st.drawMs.toFixed(1)} ms`);
1111
+ setText('m-tris', `${st.segments.toLocaleString('fr-FR')} seg.`);
1112
  setSource(shown ? 'live' : 'demo');
1113
  } else {
1114
  setSource('off');
 
1164
  function toggleRotate() {
1165
  state.rotate = !state.rotate;
1166
  setPressed('btn-rotate', state.rotate);
1167
+ if (typeof flat !== 'undefined' && flat) flat.redraw();
1168
  }
1169
 
1170
  function onKey(e) {
 
1280
  showGlMessage(glError);
1281
  // Repli : le même monde en 2D isométrique. Un clic sur un objet ouvre l'inspecteur, comme dans la 3D.
1282
  flat = createFlat($('scene'), {
1283
+ rotating: () => state.rotate,
1284
  area: () => {
1285
  const w = window.innerWidth;
1286
  const h = window.innerHeight;
web/symbioza/static/js/flat.js CHANGED
@@ -9,7 +9,9 @@ const NAMES = {
9
  mecanique: 'MÉCANIQUE & ROBOTIQUE', electronique: 'ÉLECTRONIQUE & EMBARQUÉ',
10
  biologie: 'GÉNÉTIQUE & BIO SYNTHÉTIQUE', algorithmique: 'ALGORITHMIQUE & ARCHITECTURES',
11
  };
12
- const MAX_EDGES = 1600;
 
 
13
  const BOX = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
14
 
15
  function edgesOf(vertices, faces) {
@@ -85,7 +87,7 @@ function prepare(e, i, n) {
85
  return item;
86
  }
87
 
88
- export function createFlat(host, { onPick, area } = {}) {
89
  const canvas = document.createElement('canvas');
90
  canvas.className = 'flat';
91
  host.appendChild(canvas);
@@ -97,6 +99,8 @@ export function createFlat(host, { onPick, area } = {}) {
97
  let view = { cx: 0, cy: 0, s: 40 };
98
  const started = performance.now();
99
  let last = 0;
 
 
100
 
101
  function resize() {
102
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -124,31 +128,34 @@ export function createFlat(host, { onPick, area } = {}) {
124
  return [view.cx + view.s * u, view.cy + view.s * (0.5 * v - 0.9 * y), v];
125
  }
126
 
127
- function line(a, b, color, width = 1) {
128
- const p = P(...a);
129
- const q = P(...b);
 
 
 
 
 
 
 
130
  g.strokeStyle = color;
131
  g.lineWidth = width;
132
- g.beginPath();
133
- g.moveTo(p[0], p[1]);
134
- g.lineTo(q[0], q[1]);
135
- g.stroke();
136
  }
137
 
138
- function ellipse(x, z, r, color, width = 1.5) {
139
  const c = P(x, FLOOR_Y, z);
140
- g.strokeStyle = color;
141
- g.lineWidth = width;
142
- g.beginPath();
143
- g.ellipse(c[0], c[1], r * view.s, r * view.s * 0.5, 0, 0, Math.PI * 2);
144
- g.stroke();
145
  }
146
 
147
  function drawSectors() {
 
148
  for (let k = 0; k < 4; k += 1) {
149
  const a = (k * Math.PI) / 2;
150
- line([Math.cos(a) * 3.2, FLOOR_Y, Math.sin(a) * 3.2], [Math.cos(a) * 40, FLOOR_Y, Math.sin(a) * 40], 'rgba(162, 75, 255, 0.28)');
151
  }
 
152
  g.font = `600 ${view.s > 14 ? 11 : 9}px ui-monospace, monospace`;
153
  g.textAlign = 'center';
154
  g.fillStyle = 'rgba(201, 155, 255, 0.85)';
@@ -159,7 +166,7 @@ export function createFlat(host, { onPick, area } = {}) {
159
  }
160
  }
161
 
162
- function drawRig(it, t) {
163
  const r = it.rig;
164
  const total = r.frames.length * r.dt;
165
  const tt = t % (total + 1.2);
@@ -177,45 +184,80 @@ export function createFlat(host, { onPick, area } = {}) {
177
  const w = rotate(bq, [local[0] + geo.pos[0], local[1] + geo.pos[1], local[2] + geo.pos[2]]);
178
  corners.push(toScene([w[0] + bp[0], w[1] + bp[1], w[2] + bp[2]]));
179
  }
180
- for (const [a, b] of BOX) line(corners[a], corners[b], 'rgba(214, 170, 255, 0.95)', 1.4);
181
  }
182
  }
183
 
 
 
 
 
 
 
184
  function draw(now) {
185
  requestAnimationFrame(draw);
186
- if (document.hidden || now - last < 33) return;
 
187
  last = now;
 
 
188
  const t = (now - started) / 1000;
189
- if (!drag) theta += 0.0016;
190
  g.clearRect(0, 0, window.innerWidth, window.innerHeight);
191
  drawSectors();
192
- const order = [...items].sort((a, b) => P(a.place[0], 0, a.place[1])[2] - P(b.place[0], 0, b.place[1])[2]);
193
- for (const it of order) {
 
 
 
 
 
 
194
  const [px, pz] = it.place;
195
  const e = it.entry;
196
- ellipse(px, pz, 1.45, e.lab ? STATUS[e.status] || STATUS.valide : 'rgba(255, 230, 0, 0.6)', 1.6);
197
- if (it.rig) drawRig(it, t);
198
- for (const [a, b] of it.edges) line([px + a[0], a[1], pz + a[2]], [px + b[0], b[1], pz + b[2]], 'rgba(201, 155, 255, 0.5)');
199
- for (const l of it.links) line([px + l.a[0], l.a[1], pz + l.a[2]], [px + l.b[0], l.b[1], pz + l.b[2]], LINK[l.s] || LINK[0], 1.6);
200
- for (const m of it.marks) {
201
- const p = P(px + m.p[0], m.p[1], pz + m.p[2]);
202
- g.fillStyle = m.s === 2 ? `rgba(255, 42, 77, ${0.55 + 0.45 * Math.abs(Math.sin(t * 3))})` : LINK[m.s] || LINK[1];
203
- g.beginPath();
204
- g.arc(p[0], p[1], 3.5, 0, Math.PI * 2);
205
- g.fill();
206
  }
 
 
207
  }
208
- for (const b of sites) { // chantiers : losange en fil de fer qui tourne
209
- const [x, z] = b.place;
210
- ellipse(x, z, 1.45, 'rgba(162, 75, 255, 0.9)', 1.5);
211
- const a = t * 1.5;
212
- const pts = [0, 1, 2, 3].map((k) => [x + 0.9 * Math.cos(a + (k * Math.PI) / 2), 0, z + 0.9 * Math.sin(a + (k * Math.PI) / 2)]);
213
- for (let k = 0; k < 4; k += 1) {
214
- line(pts[k], pts[(k + 1) % 4], 'rgba(162, 75, 255, 0.9)');
215
- line(pts[k], [x, 1, z], 'rgba(162, 75, 255, 0.6)');
216
- line(pts[k], [x, -1, z], 'rgba(162, 75, 255, 0.6)');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  }
 
218
  }
 
 
 
 
219
  }
220
 
221
  function pick(ev) {
@@ -233,6 +275,7 @@ export function createFlat(host, { onPick, area } = {}) {
233
  canvas.addEventListener('pointermove', (ev) => {
234
  if (!drag) return;
235
  theta += (ev.clientX - drag.x) * 0.006;
 
236
  drag.moved += Math.abs(ev.clientX - drag.x);
237
  drag.x = ev.clientX;
238
  });
@@ -240,7 +283,7 @@ export function createFlat(host, { onPick, area } = {}) {
240
  if (drag && drag.moved < 6) pick(ev);
241
  drag = null;
242
  });
243
- window.addEventListener('resize', () => { resize(); fit(); });
244
  resize();
245
  fit();
246
  requestAnimationFrame(draw);
@@ -250,7 +293,10 @@ export function createFlat(host, { onPick, area } = {}) {
250
  items = records.map((e, i) => prepare(e, i, records.length));
251
  sites = (building || []).filter((b) => Array.isArray(b.place));
252
  fit();
 
253
  return items.length;
254
  },
 
 
255
  };
256
  }
 
9
  mecanique: 'MÉCANIQUE & ROBOTIQUE', electronique: 'ÉLECTRONIQUE & EMBARQUÉ',
10
  biologie: 'GÉNÉTIQUE & BIO SYNTHÉTIQUE', algorithmique: 'ALGORITHMIQUE & ARCHITECTURES',
11
  };
12
+ const MAX_EDGES = 1600; // segments gardés par objet
13
+ const EDGE_BUDGET = 12000; // segments tracés au plus par image, tous objets confondus
14
+ const FRAME_MS = 42; // 24 images/s au plus : assez pour une rejouée, sobre pour un vieux processeur
15
  const BOX = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
16
 
17
  function edgesOf(vertices, faces) {
 
87
  return item;
88
  }
89
 
90
+ export function createFlat(host, { onPick, area, rotating } = {}) {
91
  const canvas = document.createElement('canvas');
92
  canvas.className = 'flat';
93
  host.appendChild(canvas);
 
99
  let view = { cx: 0, cy: 0, s: 40 };
100
  const started = performance.now();
101
  let last = 0;
102
+ let dirty = true;
103
+ const stats = { drawMs: 0, fps: 0, segments: 0 };
104
 
105
  function resize() {
106
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
 
128
  return [view.cx + view.s * u, view.cy + view.s * (0.5 * v - 0.9 * y), v];
129
  }
130
 
131
+ // Tracés groupés : un seul chemin par couleur, tracé une fois par image (des milliers de segments en un appel),
132
+ // et un budget de segments par image pour qu'une machine modeste ne sature jamais.
133
+ function seg(path, a, b) {
134
+ const p = P(a[0], a[1], a[2]);
135
+ const q = P(b[0], b[1], b[2]);
136
+ path.moveTo(p[0], p[1]);
137
+ path.lineTo(q[0], q[1]);
138
+ }
139
+
140
+ function stroke(path, color, width) {
141
  g.strokeStyle = color;
142
  g.lineWidth = width;
143
+ g.stroke(path);
 
 
 
144
  }
145
 
146
+ function ring(path, x, z, r) {
147
  const c = P(x, FLOOR_Y, z);
148
+ path.moveTo(c[0] + r * view.s, c[1]);
149
+ path.ellipse(c[0], c[1], r * view.s, r * view.s * 0.5, 0, 0, Math.PI * 2);
 
 
 
150
  }
151
 
152
  function drawSectors() {
153
+ const path = new Path2D();
154
  for (let k = 0; k < 4; k += 1) {
155
  const a = (k * Math.PI) / 2;
156
+ seg(path, [Math.cos(a) * 3.2, FLOOR_Y, Math.sin(a) * 3.2], [Math.cos(a) * 40, FLOOR_Y, Math.sin(a) * 40]);
157
  }
158
+ stroke(path, 'rgba(162, 75, 255, 0.28)', 1);
159
  g.font = `600 ${view.s > 14 ? 11 : 9}px ui-monospace, monospace`;
160
  g.textAlign = 'center';
161
  g.fillStyle = 'rgba(201, 155, 255, 0.85)';
 
166
  }
167
  }
168
 
169
+ function rigEdges(path, it, t) {
170
  const r = it.rig;
171
  const total = r.frames.length * r.dt;
172
  const tt = t % (total + 1.2);
 
184
  const w = rotate(bq, [local[0] + geo.pos[0], local[1] + geo.pos[1], local[2] + geo.pos[2]]);
185
  corners.push(toScene([w[0] + bp[0], w[1] + bp[1], w[2] + bp[2]]));
186
  }
187
+ for (const [a, b] of BOX) seg(path, corners[a], corners[b]);
188
  }
189
  }
190
 
191
+ // Une image n'est tracée que si quelque chose bouge (rotation, robot rejoué, chantier) ou a changé : à l'arrêt,
192
+ // la vue ne coûte rien au processeur.
193
+ function animated() {
194
+ return (rotating ? rotating() : true) || sites.length > 0 || items.some((it) => it.rig);
195
+ }
196
+
197
  function draw(now) {
198
  requestAnimationFrame(draw);
199
+ if (document.hidden || (!dirty && !animated()) || now - last < FRAME_MS) return;
200
+ const interval = now - last;
201
  last = now;
202
+ dirty = false;
203
+ const t0 = performance.now();
204
  const t = (now - started) / 1000;
205
+ if (!drag && (rotating ? rotating() : true)) theta += 0.0016 * Math.min(interval, 200) / 33;
206
  g.clearRect(0, 0, window.innerWidth, window.innerHeight);
207
  drawSectors();
208
+ const total = items.reduce((n, it) => n + it.edges.length, 0);
209
+ const stride = Math.max(1, Math.ceil(total / EDGE_BUDGET));
210
+ const edges = new Path2D();
211
+ const rigs = new Path2D();
212
+ const links = [new Path2D(), new Path2D(), new Path2D()];
213
+ const rings = { valide: new Path2D(), rupture: new Path2D(), libre: new Path2D() };
214
+ const marks = [[], [], []];
215
+ for (const it of items) {
216
  const [px, pz] = it.place;
217
  const e = it.entry;
218
+ ring(rings[e.lab ? (e.status === 'rupture' ? 'rupture' : 'valide') : 'libre'], px, pz, 1.45);
219
+ if (it.rig) rigEdges(rigs, it, t);
220
+ for (let k = 0; k < it.edges.length; k += stride) {
221
+ const [a, b] = it.edges[k];
222
+ seg(edges, [px + a[0], a[1], pz + a[2]], [px + b[0], b[1], pz + b[2]]);
 
 
 
 
 
223
  }
224
+ for (const l of it.links) seg(links[l.s] || links[0], [px + l.a[0], l.a[1], pz + l.a[2]], [px + l.b[0], l.b[1], pz + l.b[2]]);
225
+ for (const m of it.marks) marks[m.s in marks ? m.s : 2].push(P(px + m.p[0], m.p[1], pz + m.p[2]));
226
  }
227
+ stroke(rings.libre, 'rgba(255, 230, 0, 0.6)', 1.6);
228
+ stroke(rings.valide, STATUS.valide, 1.6);
229
+ stroke(rings.rupture, STATUS.rupture, 1.8);
230
+ stroke(edges, 'rgba(201, 155, 255, 0.5)', 1);
231
+ stroke(rigs, 'rgba(214, 170, 255, 0.95)', 1.4);
232
+ links.forEach((path, k) => stroke(path, LINK[k], 1.6));
233
+ marks.forEach((pts, k) => {
234
+ if (!pts.length) return;
235
+ const path = new Path2D();
236
+ for (const p of pts) { path.moveTo(p[0] + 3.5, p[1]); path.arc(p[0], p[1], 3.5, 0, Math.PI * 2); }
237
+ g.globalAlpha = k === 2 ? 0.55 + 0.45 * Math.abs(Math.sin(t * 3)) : 1;
238
+ g.fillStyle = LINK[k];
239
+ g.fill(path);
240
+ g.globalAlpha = 1;
241
+ });
242
+ if (sites.length) { // chantiers : losange en fil de fer qui tourne
243
+ const path = new Path2D();
244
+ for (const b of sites) {
245
+ const [x, z] = b.place;
246
+ ring(path, x, z, 1.45);
247
+ const a = t * 1.5;
248
+ const pts = [0, 1, 2, 3].map((k) => [x + 0.9 * Math.cos(a + (k * Math.PI) / 2), 0, z + 0.9 * Math.sin(a + (k * Math.PI) / 2)]);
249
+ for (let k = 0; k < 4; k += 1) {
250
+ seg(path, pts[k], pts[(k + 1) % 4]);
251
+ seg(path, pts[k], [x, 1, z]);
252
+ seg(path, pts[k], [x, -1, z]);
253
+ }
254
  }
255
+ stroke(path, 'rgba(162, 75, 255, 0.85)', 1.3);
256
  }
257
+ const spent = performance.now() - t0;
258
+ stats.drawMs = stats.drawMs ? stats.drawMs * 0.9 + spent * 0.1 : spent;
259
+ stats.fps = interval > 0 && interval < 1000 ? (stats.fps ? stats.fps * 0.9 + (1000 / interval) * 0.1 : 1000 / interval) : stats.fps;
260
+ stats.segments = Math.ceil(total / stride);
261
  }
262
 
263
  function pick(ev) {
 
275
  canvas.addEventListener('pointermove', (ev) => {
276
  if (!drag) return;
277
  theta += (ev.clientX - drag.x) * 0.006;
278
+ dirty = true;
279
  drag.moved += Math.abs(ev.clientX - drag.x);
280
  drag.x = ev.clientX;
281
  });
 
283
  if (drag && drag.moved < 6) pick(ev);
284
  drag = null;
285
  });
286
+ window.addEventListener('resize', () => { resize(); fit(); dirty = true; });
287
  resize();
288
  fit();
289
  requestAnimationFrame(draw);
 
293
  items = records.map((e, i) => prepare(e, i, records.length));
294
  sites = (building || []).filter((b) => Array.isArray(b.place));
295
  fit();
296
+ dirty = true;
297
  return items.length;
298
  },
299
+ redraw() { dirty = true; },
300
+ stats() { return { ...stats, animated: animated() }; },
301
  };
302
  }