1from pathlib
import Path
2import pandas
as pd, json, threading, urllib.parse
3from http.server
import HTTPServer, BaseHTTPRequestHandler
4from IPython.display
import display, IFrame
14from .isiCellPy
import Simu, CellVector
16STATIC_DIR = Path(__file__).resolve().parent /
'static'
19 for port
in range(start, end + 1):
20 with socket.socket(socket.AF_INET, socket.SOCK_STREAM)
as s:
22 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
29 raise RuntimeError(f
"No free port in range {start}-{end}")
36 """Return the path to settings.json: workspace first, then user."""
39 ws_settings = cwd /
".vscode" /
"settings.json"
40 if ws_settings.exists():
45 system = platform.system()
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')
53 raise FileNotFoundError(
"APPDATA not set on Windows")
54 user_settings = Path(appdata) /
"Code/User/settings.json"
56 raise OSError(f
"Unsupported system {system!r}")
58 if user_settings.exists():
61 raise FileNotFoundError(f
"settings.json not found at: {user_settings}")
65 """Return the default theme name and the VS Code resources directory."""
66 system = platform.system()
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"
75 raise OSError(f
"Unsupported system {system!r}")
77 prod_file = resources /
"product.json"
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()
83 return (stripped
or 'Dark+'), resources
86 return 'Dark+', resources
90 """Normalize a string to lowercase alphanumeric only."""
91 return re.sub(
r'[^a-z0-9]',
'', text.lower()
if text
else '')
95 """Return True if either normalized name matches or contains the target."""
96 if not norm_name
or not norm_target:
98 return norm_name == norm_target
or norm_name
in norm_target
or norm_target
in norm_name
102 """Scan base_dir/*/themes/*.json to find a matching theme."""
103 if not base_dir.exists():
106 for ext
in sorted(base_dir.iterdir()):
107 themes_dir = ext /
"themes"
108 if not themes_dir.exists():
111 for f
in sorted(themes_dir.glob(
"*.json")):
113 data = json.loads(f.read_text(encoding=
'utf-8'))
117 name_field = data.get(
"name")
or data.get(
"label")
or ''
125 """Find the JSON file for the given VS Code theme."""
128 settings = json.loads(settings_path.read_text(encoding=
'utf-8'))
129 theme = theme_name
or settings.get(
"workbench.colorTheme")
132 name = theme
or default
136 user_ext = Path.home() /
".vscode" /
"extensions"
142 builtin_ext = resources_app /
"extensions"
147 raise FileNotFoundError(f
"Theme '{name}' not found in {user_ext} or {builtin_ext}")
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):
157 inc = data.get(
"include")
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():
177 except Exception
as e:
182 Lit le fichier à `path`, puis pour chaque {{ expr }} dans le texte
183 évalue `expr` avec eval(expr, {}, context) et remplace par str(result).
185 context : dict des variables disponibles dans vos expressions.
187 context = {} if context
is None else context
190 text = (STATIC_DIR /
'templates' / file).read_text()
193 pattern = re.compile(
r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)
196 expr = match.group(1)
199 result = eval(expr, {}, context)
200 except Exception
as e:
202 print(f
"Erreur lors de l'évaluation de l'expression '{expr}': {e}")
203 return match.group(0)
206 return pattern.sub(_repl, text).encode(
'utf-8')
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)
223 self.wfile.write(b
'Colors updated successfully.')
225 self.send_error(404,
"Not Found")
228 url = urllib.parse.urlparse(self.path)
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'):
236 df0 = df[df[
'step'] == step]
238 df0 = self.server.df_get(step)
240 self.
generic_response(
'data.json', body=json.dumps({
"error":
"No data for this step"}).encode())
242 query = params.get(
'filter', [
''])[0]
245 df0 = df0.query(query)
246 except Exception
as e:
247 self.
generic_response(
'data.json', body=json.dumps({
"error": str(e)}).encode())
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()
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()
263 body = json.dumps(payload).encode()
266 elif url.path ==
'/':
268 if hasattr(self.server,
'df'):
269 steps = sorted(self.server.df[
'step'].unique())
272 steps = self.server.steps
273 df = self.server.df_get(0)
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())
281 'dt': self.server.dt,
283 'is3D': self.server.is3D,
284 'isDebug': self.server.isDebug,
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}
289 elif '..' not in url.path:
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',
'*')
299 self.wfile.write(body)
301 self.wfile.write((STATIC_DIR / path[1:]).read_bytes())
302 except FileNotFoundError
as e:
307 """Stop the HTTPServer and join its thread."""
311 server.server_close()
315 if thread.is_alive():
323 A live or replay 2D/3D simulation viewer embedded
in Jupyter.
325 This
class starts an HTTPServer in a background
thread.
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.
331 The server
is automatically cleaned up when the Viewer object
is
332 garbage-collected
or when you explicitly call `.
stop()`.
336 Instantiate and start the HTTP server.
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.
346 RuntimeError: If the server thread fails to start within 5 seconds.
349 self.
server = HTTPServer((
'localhost', self.
port), StepHandler)
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)
357 timeout = time.time() + 5.0
358 while not self.
thread.is_alive():
359 if time.time() > timeout:
361 self.
server.server_close()
362 raise RuntimeError(
"Le serveur n'a pas pu démarrer à temps.")
367 def replay(self, df: pd.DataFrame, dt: float) ->
"Viewer":
369 Load a precomputed DataFrame for step-by-step replay.
372 df (pd.DataFrame): Must contain columns [
'step',
'id',…,
'positionx',…].
373 dt (float): Time interval between steps.
376 self,
for method chaining.
379 RuntimeError: If already
in live mode.
380 TypeError: If df
is not a pandas DataFrame.
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)
388 self.
server.isReplay =
True
389 self.
server.steps = sorted(df.step.unique())
393 def live(self, params: dict, infos: list[str]|str = [
'type',
'state'], seed: int =
None, benchmark: bool =
False, withTqdm: bool =
False) ->
"Viewer":
395 Run a Simu instance live, collect data each step, and stream it.
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.
403 self,
for method chaining.
406 RuntimeError: If already
in replay mode.
409 raise RuntimeError(
"Le viewer est déjà en mode replay.")
410 maxStep = params[
'input'][
'Scenario'][
'maxStep']
411 dt = params[
'input'][
'Scenario'][
'dt']
414 infos = CellVector.attrs
416 infos = list(set(infos) | set([
'id',
'positionx',
'positiony',
'positionz',
'radius']))
418 infos = list(set(infos) | set([
'id',
'positionx',
'positiony']))
420 def dataCatcher(simu):
421 return simu.cells.getPandas(infos).sort_values(
'id').reset_index(drop=
True)
424 return datas[step]
if step < len(datas)
else None
426 self.
server.df_get = df_get
428 self.
server.steps = list(range(maxStep))
430 simu = Simu(params,benchmark=benchmark)
432 simu = Simu(params, seed=seed, benchmark=benchmark)
433 datas.append(dataCatcher(simu))
437 stepIter = tqdm(simu.iterSteps(self.
server.steps[1:]), total=maxStep-1, desc=
"Processing steps")
439 stepIter = simu.iterSteps(self.
server.steps[1:])
441 datas.append(dataCatcher(simu))
445 simu.showBenchmarksSummaries()
451 Returns a single concatenated DataFrame of all steps collected in live mode.
452 If replay mode was used, returns the original df.
455 pd.DataFrame: Index “step”,
with all columns
from dataCatcher.
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()
463 def show(self, width: int =
None, height: int =
None):
465 Display the viewer inline in a notebook via IFrame.
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.
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):
475 display(IFrame(src=f
"http://localhost:{self.port}", width=width, height=height))
479 Jupyter HTML repr: Embed the viewer automatically when returning this object.
481 while (
not self.
server.ready):
483 return f
'<iframe src="http://localhost:{self.port}" width="100%" height="800px" frameborder="0"></iframe>'
487 Stop the HTTP server and join its thread immediately.
488 Also prevents the automatic finalizer
from running again.
def log_message(self, format, *args)
def generic_response(self, path, body=None)
"Viewer" replay(self, pd.DataFrame df, float dt)
def __init__(self, is3D, port=None, debug=False)
def show(self, int width=None, int height=None)
"Viewer" live(self, dict params, list[str]|str infos=['type', 'state'], int seed=None, bool benchmark=False, bool withTqdm=False)
pd.DataFrame getData(self)
double max(double a, double b)
Computes the maximum of two numbers.
double min(double a, double b)
Computes the minimum of two numbers.
def load_theme_colors(theme_path)
def matches(norm_name, norm_target)
def find_theme_file(theme_name=None)
def render_template(file, context=None)
def find_vscode_settings()
VSCODE THEME COLOR ####################################.
def scan_for_theme(base_dir, norm_target)
def get_default_theme_resources()
def _cleanup(server, thread)
def find_free_port(start=8000, end=9000, host='localhost')