CellModules
viewer.py
Go to the documentation of this file.
1from pathlib import Path
2import pandas as pd, json, threading, urllib.parse
3from http.server import HTTPServer, BaseHTTPRequestHandler
4from IPython.display import display, IFrame
5import mimetypes
6import json
7import re
8import time
9import weakref
10import socket
11from tqdm import tqdm
12import os
13import platform
14from .isiCellPy import Simu, CellVector
15
16STATIC_DIR = Path(__file__).resolve().parent / 'static'
17
18def find_free_port(start=8000, end=9000, host='localhost'):
19 for port in range(start, end + 1):
20 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
21 # permet de réutiliser immédiatement un port TIME_WAIT
22 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
23 try:
24 s.bind((host, port))
25 return port
26 except OSError:
27 # port occupé, on essaie le suivant
28 continue
29 raise RuntimeError(f"No free port in range {start}-{end}")
30
31
34
36 """Return the path to settings.json: workspace first, then user."""
37 # 1) Workspace settings
38 cwd = Path.cwd()
39 ws_settings = cwd / ".vscode" / "settings.json"
40 if ws_settings.exists():
41 return ws_settings
42
43 # 2) User settings
44 home = Path.home()
45 system = platform.system()
46 if system == 'Linux':
47 user_settings = home / ".config/Code/User/settings.json"
48 elif system == 'Darwin':
49 user_settings = home / "Library/Application Support/Code/User/settings.json"
50 elif system == 'Windows':
51 appdata = os.environ.get('APPDATA')
52 if not appdata:
53 raise FileNotFoundError("APPDATA not set on Windows")
54 user_settings = Path(appdata) / "Code/User/settings.json"
55 else:
56 raise OSError(f"Unsupported system {system!r}")
57
58 if user_settings.exists():
59 return user_settings
60
61 raise FileNotFoundError(f"settings.json not found at: {user_settings}")
62
63
65 """Return the default theme name and the VS Code resources directory."""
66 system = platform.system()
67 if system == 'Linux':
68 resources = Path("/usr/share/code/resources/app")
69 elif system == 'Darwin':
70 resources = Path("/Applications/Visual Studio Code.app/Contents/Resources/app")
71 elif system == 'Windows':
72 pf = os.environ.get('ProgramFiles', 'C:/Program Files')
73 resources = Path(pf) / "Microsoft VS Code/resources/app"
74 else:
75 raise OSError(f"Unsupported system {system!r}")
76
77 prod_file = resources / "product.json"
78 try:
79 cfg = json.loads(prod_file.read_text(encoding='utf-8'))
80 default_theme = cfg.get('defaultColorTheme', '')
81 stripped = re.sub(r'(?i)^default\s*', '', default_theme).strip()
82 # Fallback to Dark+ if none specified
83 return (stripped or 'Dark+'), resources
84 except Exception:
85 # On any error, default back to Dark+
86 return 'Dark+', resources
87
88
89def normalize(text):
90 """Normalize a string to lowercase alphanumeric only."""
91 return re.sub(r'[^a-z0-9]', '', text.lower() if text else '')
92
93
94def matches(norm_name, norm_target):
95 """Return True if either normalized name matches or contains the target."""
96 if not norm_name or not norm_target:
97 return False
98 return norm_name == norm_target or norm_name in norm_target or norm_target in norm_name
99
100
101def scan_for_theme(base_dir, norm_target):
102 """Scan base_dir/*/themes/*.json to find a matching theme."""
103 if not base_dir.exists():
104 return None
105
106 for ext in sorted(base_dir.iterdir()):
107 themes_dir = ext / "themes"
108 if not themes_dir.exists():
109 continue
110
111 for f in sorted(themes_dir.glob("*.json")):
112 try:
113 data = json.loads(f.read_text(encoding='utf-8'))
114 except Exception:
115 continue
116
117 name_field = data.get("name") or data.get("label") or ''
118 if matches(normalize(name_field), norm_target) or matches(normalize(f.stem), norm_target):
119 return f
120
121 return None
122
123
124def find_theme_file(theme_name=None):
125 """Find the JSON file for the given VS Code theme."""
126 # Determine theme name: workspace/user or default
127 settings_path = find_vscode_settings()
128 settings = json.loads(settings_path.read_text(encoding='utf-8'))
129 theme = theme_name or settings.get("workbench.colorTheme")
130
131 default, resources_app = get_default_theme_resources()
132 name = theme or default
133 norm_target = normalize(name)
134
135 # 1) User extensions directory
136 user_ext = Path.home() / ".vscode" / "extensions"
137 file = scan_for_theme(user_ext, norm_target)
138 if file:
139 return file
140
141 # 2) Built-in extensions directory
142 builtin_ext = resources_app / "extensions"
143 file = scan_for_theme(builtin_ext, norm_target)
144 if file:
145 return file
146
147 raise FileNotFoundError(f"Theme '{name}' not found in {user_ext} or {builtin_ext}")
148
149
150def load_theme_colors(theme_path):
151 """Load and return the 'colors' dict from the theme JSON, following includes if needed."""
152 data = json.loads(theme_path.read_text(encoding='utf-8'))
153 colors = data.get("colors")
154 if isinstance(colors, dict):
155 return colors
156
157 inc = data.get("include")
158 if inc:
159 inc_path = theme_path.parent / inc
160 if not inc_path.exists() and inc.startswith("./"):
161 inc_path = theme_path.parent / inc[2:]
162 if inc_path.exists():
163 return load_theme_colors(inc_path)
164
165 return {}
166
167
170
172 try:
173 theme_name = None
174 theme_file = find_theme_file(theme_name)
175 colors = load_theme_colors(theme_file)
176 return colors
177 except Exception as e:
178 return {}
179
180def render_template(file, context=None):
181 """
182 Lit le fichier à `path`, puis pour chaque {{ expr }} dans le texte
183 évalue `expr` avec eval(expr, {}, context) et remplace par str(result).
184
185 context : dict des variables disponibles dans vos expressions.
186 """
187 context = {} if context is None else context
188 # Lecture brute
189
190 text = (STATIC_DIR / 'templates' / file).read_text()
191
192 # Regex pour capturer {{ ... }} non-greedy
193 pattern = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)
194
195 def _repl(match):
196 expr = match.group(1)
197 try:
198 # Évaluer dans un contexte silencieux
199 result = eval(expr, {}, context)
200 except Exception as e:
201 # En cas d’erreur, on restitue l’original (ou lever)
202 print(f"Erreur lors de l'évaluation de l'expression '{expr}': {e}")
203 return match.group(0)
204 return str(result)
205
206 return pattern.sub(_repl, text).encode('utf-8')
207
208class StepHandler(BaseHTTPRequestHandler):
209
210 def log_message(self, format, *args):
211 pass
212
213 def do_POST(self):
214 url = urllib.parse.urlparse(self.path)
215 if url.path == '/update_colors':
216 content_length = int(self.headers['Content-Length'])
217 body = self.rfile.read(content_length)
218 colors = json.loads(body.decode('utf-8'))
219 with open(STATIC_DIR / 'config' / 'colors.json', 'w') as f:
220 json.dump(colors, f, indent=2)
221 self.send_response(200)
222 self.end_headers()
223 self.wfile.write(b'Colors updated successfully.')
224 else:
225 self.send_error(404, "Not Found")
226
227 def do_GET(self):
228 url = urllib.parse.urlparse(self.path)
229
230 if url.path == '/data':
231 params = urllib.parse.parse_qs(url.query)
232 step = int(params.get('step', ['0'])[0])
233 feature = params.get('feature', [''])[0]
234 if hasattr(self.server, 'df'):
235 df = self.server.df
236 df0 = df[df['step'] == step]
237 else:
238 df0 = self.server.df_get(step)
239 if df0 is None:
240 self.generic_response('data.json', body=json.dumps({"error": "No data for this step"}).encode())
241 return
242 query = params.get('filter', [''])[0]
243 if query:
244 try:
245 df0 = df0.query(query)
246 except Exception as e:
247 self.generic_response('data.json', body=json.dumps({"error": str(e)}).encode())
248 return
249 if self.server.is3D:
250 payload = {
251 'x': df0['positionx'].tolist(),
252 'y': df0['positiony'].tolist(),
253 'z': df0['positionz'].tolist(),
254 'radius': df0['radius'].tolist(),
255 'info': df0[feature].cat.codes.tolist() if isinstance(df0[feature].dtype, pd.CategoricalDtype) else df0[feature].tolist()
256 }
257 else:
258 payload = {
259 'x': df0['positionx'].tolist(),
260 'y': df0['positiony'].tolist(),
261 'info': df0[feature].cat.codes.tolist() if isinstance(df0[feature].dtype, pd.CategoricalDtype) else df0[feature].tolist()
262 }
263 body = json.dumps(payload).encode()
264 self.generic_response('data.json', body=body)
265
266 elif url.path == '/':
267
268 if hasattr(self.server, 'df'):
269 steps = sorted(self.server.df['step'].unique())
270 df = self.server.df
271 else:
272 steps = self.server.steps
273 df = self.server.df_get(0)
274
275 feats = [c for c in df.columns if c not in ('step','id','positionx','positiony','positionz','radius')]
276 colors = json.loads((STATIC_DIR / 'config' / 'colors.json').read_text())
277 html = render_template('index.html',
278 context={
279 'json': json,
280 'steps': steps,
281 'dt': self.server.dt,
282 'colors': colors,
283 'is3D': self.server.is3D,
284 'isDebug': self.server.isDebug,
285 'themeColors': get_colors(),
286 'features': {f: ({'isEnum':True,'values':list(df[f].dtypes.categories)} if isinstance(df[f].dtype, pd.CategoricalDtype) else {'isEnum':False,'min':float(df[f].min()),'max':float(df[f].max())}) for f in feats}
287 })
288 self.generic_response('index.html', body=html)
289 elif '..' not in url.path:
290 self.generic_response(url.path)
291
292 def generic_response(self, path, body=None):
293 self.send_response(200)
294 self.send_header('Content-Type', (mimetypes.guess_type(path)[0] or 'application/octet-stream'))
295 self.send_header('Access-Control-Allow-Origin', '*')
296 self.end_headers()
297 try:
298 if body:
299 self.wfile.write(body)
300 else:
301 self.wfile.write((STATIC_DIR / path[1:]).read_bytes())
302 except FileNotFoundError as e:
303 print(e)
304 self.send_error(404)
305
306def _cleanup(server, thread):
307 """Stop the HTTPServer and join its thread."""
308 try:
309 #print("Arrêt du serveur HTTP...")
310 server.shutdown()
311 server.server_close()
312 except Exception:
313 pass
314 try:
315 if thread.is_alive():
316 thread.join()
317 except Exception:
318 pass
319
320
321class Viewer:
322 """
323 A live or replay 2D/3D simulation viewer embedded in Jupyter.
324
325 This class starts an HTTPServer in a background thread.
326
327 You can use it in **replay** mode—feeding it a precomputed DataFrame—or
328 in **live** mode—running a Simu instance step by step and streaming
329 results as they arrive.
330
331 The server is automatically cleaned up when the Viewer object is
332 garbage-collected or when you explicitly call `.stop()`.
333 """
334 def __init__(self,is3D,port=None,debug=False):
335 """
336 Instantiate and start the HTTP server.
337
338 Args:
339 is3D (bool): If True, viewer expects 3D coordinates (x,y,z),
340 otherwise only (x,y).
341 port (int, optional): TCP port to bind the server on. If None,
342 tries to find a free port in [8765, 8865].
343 debug (bool): If True, enables javascript debug mode with additional logging.
344
345 Raises:
346 RuntimeError: If the server thread fails to start within 5 seconds.
347 """
348 self.port = port or find_free_port(start=8765, end=8765+100)
349 self.server = HTTPServer(('localhost', self.port), StepHandler)
350 self.server.is3D = is3D
351 self.server.ready = False
352 self.server.isLive = False
353 self.server.isReplay = False
354 self.server.isDebug = debug
355 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
356 self.thread.start()
357 timeout = time.time() + 5.0 # 5 secondes max
358 while not self.thread.is_alive():
359 if time.time() > timeout:
360 self.server.shutdown()
361 self.server.server_close()
362 raise RuntimeError("Le serveur n'a pas pu démarrer à temps.")
363 time.sleep(0.01)
364
365 self._finalizer = weakref.finalize(self, _cleanup, self.server, self.thread)
366
367 def replay(self, df: pd.DataFrame, dt: float) -> "Viewer":
368 """
369 Load a precomputed DataFrame for step-by-step replay.
370
371 Args:
372 df (pd.DataFrame): Must contain columns ['step','id',…,'positionx',…].
373 dt (float): Time interval between steps.
374
375 Returns:
376 self, for method chaining.
377
378 Raises:
379 RuntimeError: If already in live mode.
380 TypeError: If df is not a pandas DataFrame.
381 """
382 if self.server.isLive:
383 raise RuntimeError("Le viewer est déjà en mode live.")
384 if not isinstance(df, pd.DataFrame):
385 raise TypeError("df doit être un DataFrame Pandas.")
386 self.server.df = df.sort_values(by=['step','id']).reset_index(drop=True)
387 self.server.dt = dt
388 self.server.isReplay = True
389 self.server.steps = sorted(df.step.unique())
390 self.server.ready = True
391 return self
392
393 def live(self, params: dict, infos: list[str]|str = ['type', 'state'], seed: int = None, benchmark: bool = False, withTqdm: bool = False) -> "Viewer":
394 """
395 Run a Simu instance live, collect data each step, and stream it.
396
397 Args:
398 params (dict): Simulation parameters (must include Scenario.maxStep & dt).
399 infos (list[str]|str): List of cell attributes to retrieve, or 'all'
400 withTqdm (bool): If True, show a tqdm progress bar.
401
402 Returns:
403 self, for method chaining.
404
405 Raises:
406 RuntimeError: If already in replay mode.
407 """
408 if self.server.isReplay:
409 raise RuntimeError("Le viewer est déjà en mode replay.")
410 maxStep = params['input']['Scenario']['maxStep']
411 dt = params['input']['Scenario']['dt']
412 datas = []
413 if infos == 'all':
414 infos = CellVector.attrs
415 if self.server.is3D:
416 infos = list(set(infos) | set(['id','positionx','positiony','positionz','radius']))
417 else:
418 infos = list(set(infos) | set(['id','positionx','positiony']))
419
420 def dataCatcher(simu):
421 return simu.cells.getPandas(infos).sort_values('id').reset_index(drop=True)
422
423 def df_get(step):
424 return datas[step] if step < len(datas) else None
425
426 self.server.df_get = df_get
427 self.server.dt = dt
428 self.server.steps = list(range(maxStep))
429 if seed is None:
430 simu = Simu(params,benchmark=benchmark)
431 else:
432 simu = Simu(params, seed=seed, benchmark=benchmark)
433 datas.append(dataCatcher(simu))
434 self.server.ready = True
435 self.show()
436 if withTqdm:
437 stepIter = tqdm(simu.iterSteps(self.server.steps[1:]), total=maxStep-1, desc="Processing steps")
438 else:
439 stepIter = simu.iterSteps(self.server.steps[1:])
440 for s in stepIter:
441 datas.append(dataCatcher(simu))
442
443 self.datas = datas
444 if benchmark:
445 simu.showBenchmarksSummaries()
446 self.simu = simu
447 return self
448
449 def getData(self) -> pd.DataFrame:
450 """
451 Returns a single concatenated DataFrame of all steps collected in live mode.
452 If replay mode was used, returns the original df.
453
454 Returns:
455 pd.DataFrame: Index “step”, with all columns from dataCatcher.
456
457 """
458 if not hasattr(self.server, 'df'):
459 self.server.df = pd.concat(self.datas, keys=self.server.steps, names=['step'], copy=False).droplevel(1).reset_index()
460 del self.server.df_get
461 return self.server.df
462
463 def show(self, width: int = None, height: int = None):
464 """
465 Display the viewer inline in a notebook via IFrame.
466
467 Args:
468 width (int, optional): Width of the plotting area in px; if None uses 100% width.
469 height (int, optional): Height in px; if None uses a default of 800px.
470 """
471 width = f"{width+250}px" if width else "100%"
472 height = f"{height}px" if height else "800px"
473 while (not self.server.ready):
474 time.sleep(0.01)
475 display(IFrame(src=f"http://localhost:{self.port}", width=width, height=height))
476
477 def _repr_html_(self) -> str:
478 """
479 Jupyter HTML repr: Embed the viewer automatically when returning this object.
480 """
481 while (not self.server.ready):
482 time.sleep(0.01)
483 return f'<iframe src="http://localhost:{self.port}" width="100%" height="800px" frameborder="0"></iframe>'
484
485 def stop(self):
486 """
487 Stop the HTTP server and join its thread immediately.
488 Also prevents the automatic finalizer from running again.
489 """
490 self._finalizer()
def do_POST(self)
Definition: viewer.py:213
def log_message(self, format, *args)
Definition: viewer.py:210
def generic_response(self, path, body=None)
Definition: viewer.py:292
def do_GET(self)
Definition: viewer.py:227
str _repr_html_(self)
Definition: viewer.py:477
"Viewer" replay(self, pd.DataFrame df, float dt)
Definition: viewer.py:367
def __init__(self, is3D, port=None, debug=False)
Definition: viewer.py:334
def show(self, int width=None, int height=None)
Definition: viewer.py:463
def stop(self)
Definition: viewer.py:485
"Viewer" live(self, dict params, list[str]|str infos=['type', 'state'], int seed=None, bool benchmark=False, bool withTqdm=False)
Definition: viewer.py:393
pd.DataFrame getData(self)
Definition: viewer.py:449
double max(double a, double b)
Computes the maximum of two numbers.
Definition: std.hpp:280
double min(double a, double b)
Computes the minimum of two numbers.
Definition: std.hpp:291
def get_colors()
Definition: viewer.py:171
def load_theme_colors(theme_path)
Definition: viewer.py:150
def matches(norm_name, norm_target)
Definition: viewer.py:94
def find_theme_file(theme_name=None)
Definition: viewer.py:124
def render_template(file, context=None)
Definition: viewer.py:180
def find_vscode_settings()
VSCODE THEME COLOR ####################################.
Definition: viewer.py:35
def scan_for_theme(base_dir, norm_target)
Definition: viewer.py:101
def normalize(text)
Definition: viewer.py:89
def get_default_theme_resources()
Definition: viewer.py:64
def _cleanup(server, thread)
Definition: viewer.py:306
def find_free_port(start=8000, end=9000, host='localhost')
Definition: viewer.py:18