cfs-python 0.1.0__py3-none-any.whl

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.
app_panel.py ADDED
@@ -0,0 +1,1058 @@
1
+ """
2
+ Coulomb Stress Change – Interactive Panel Dashboard
3
+ ====================================================
4
+ Run with: panel serve app_panel.py --show
5
+ """
6
+ import io, os, tempfile
7
+ import numpy as np
8
+ import pandas as pd
9
+ import panel as pn
10
+ import plotly.graph_objects as go
11
+ import geopandas as gpd
12
+ from shapely.geometry import Point
13
+ import rasterio
14
+ from rasterio.transform import from_bounds
15
+
16
+ from cfs_lib.io_parser import open_input_file_cui, open_batch_file
17
+ from cfs_lib.okada_wrapper import okada_elastic_halfspace
18
+ from cfs_lib.coulomb_math import calc_coulomb
19
+
20
+ pn.extension("plotly", sizing_mode="stretch_width", theme="dark")
21
+ os.makedirs("cache", exist_ok=True)
22
+
23
+ # =====================================================================
24
+ # STATE
25
+ # =====================================================================
26
+ STATE = {
27
+ "el": None, # fault elements (n, 9)
28
+ "kode": None,
29
+ "pois": 0.25,
30
+ "young": 8e5,
31
+ "fric": 0.4,
32
+ "cdepth": 5.0,
33
+ "xvec": None, # 1-D grid vectors
34
+ "yvec": None,
35
+ "dc3d": None, # full output (ncell, 14)
36
+ "shear": None,
37
+ "normal_stress": None,
38
+ "coulomb_stress": None,
39
+ "nx": 0,
40
+ "ny": 0,
41
+ "xsec_data": None, # Store cross-section dataframe for download
42
+ "click_state": "A", # Which point (A or B) to set next on map click
43
+ }
44
+
45
+ # =====================================================================
46
+ # WIDGETS – Sidebar
47
+ # =====================================================================
48
+ file_input = pn.widgets.FileInput(name="Source file (.inp)", accept=".inp,.dat", multiple=False)
49
+ batch_input = pn.widgets.FileInput(name="Batch / Single-point file (.dat)", accept=".dat,.txt", multiple=False)
50
+
51
+ calc_mode = pn.widgets.Select(
52
+ name="Calculation Mode",
53
+ options={
54
+ "Grid – Coulomb Stress": "coulomb",
55
+ "Grid – Deformation": "deformation",
56
+ "Batch (file-based)": "batch",
57
+ "Single Target Point": "single",
58
+ },
59
+ value="coulomb",
60
+ )
61
+
62
+ # Grid limits
63
+ grid_xmin = pn.widgets.FloatInput(name="X min (km)", value=-50., step=1.)
64
+ grid_xmax = pn.widgets.FloatInput(name="X max (km)", value=50., step=1.)
65
+ grid_ymin = pn.widgets.FloatInput(name="Y min (km)", value=-50., step=1.)
66
+ grid_ymax = pn.widgets.FloatInput(name="Y max (km)", value=50., step=1.)
67
+ grid_inc = pn.widgets.FloatInput(name="Spacing (km)", value=5., step=0.1, start=0.1)
68
+ grid_depth = pn.widgets.FloatInput(name="Depth (km)", value=5., step=0.1)
69
+
70
+ # Receiver angles
71
+ rec_strike = pn.widgets.FloatInput(name="R. Strike (°)", value=30.)
72
+ rec_dip = pn.widgets.FloatInput(name="R. Dip (°)", value=90.)
73
+ rec_rake = pn.widgets.FloatInput(name="R. Rake (°)", value=180.)
74
+
75
+ # Single point
76
+ sp_x = pn.widgets.FloatInput(name="X (km)", value=0.)
77
+ sp_y = pn.widgets.FloatInput(name="Y (km)", value=0.)
78
+ sp_z = pn.widgets.FloatInput(name="Z (km)", value=5.)
79
+ sp_strike = pn.widgets.FloatInput(name="Strike (°)", value=30.)
80
+ sp_dip = pn.widgets.FloatInput(name="Dip (°)", value=90.)
81
+ sp_rake = pn.widgets.FloatInput(name="Rake (°)", value=180.)
82
+
83
+ run_btn = pn.widgets.Button(name="▶ Run Calculation", button_type="primary",
84
+ sizing_mode="stretch_width")
85
+ status_md = pn.pane.Markdown("**Status:** waiting for input file",
86
+ styles={"color": "#999"})
87
+
88
+ # =====================================================================
89
+ # DISPLAY WIDGETS – Main area
90
+ # =====================================================================
91
+ VAR_OPTIONS = ["ux", "uy", "uz",
92
+ "sxx", "syy", "szz", "sxy", "sxz", "syz",
93
+ "shear", "normal", "coulomb"]
94
+ COLORSCALES = [
95
+ "Jet", "Viridis", "Plasma", "Inferno", "Rainbow", "Cividis",
96
+ "balance", "RdBu", "PiYG", "PRGn", "BrBG", "PuOr"
97
+ ]
98
+
99
+ plot_var = pn.widgets.Select(name="Plot variable", options=VAR_OPTIONS, value="coulomb")
100
+ map_cmap = pn.widgets.Select(name="Color Map", options=COLORSCALES, value="Jet", width=120)
101
+ map_vmin = pn.widgets.FloatInput(name="Min value", value=None, width=100)
102
+ map_vmax = pn.widgets.FloatInput(name="Max value", value=None, width=100)
103
+ toggle_3d = pn.widgets.Checkbox(name="3D view", value=False)
104
+ refresh_btn = pn.widgets.Button(name="🔄 Refresh Map", button_type="warning", width=150)
105
+
106
+ map_pane = pn.pane.Plotly(go.Figure(), height=650, sizing_mode="stretch_width")
107
+
108
+ # --- Cross-section widgets ---
109
+ xsec_ax = pn.widgets.FloatInput(name="A → X", value=-30., step=1.)
110
+ xsec_ay = pn.widgets.FloatInput(name="A → Y", value=0., step=1.)
111
+ xsec_bx = pn.widgets.FloatInput(name="B → X", value=30., step=1.)
112
+ xsec_by = pn.widgets.FloatInput(name="B → Y", value=0., step=1.)
113
+ xsec_zmin = pn.widgets.FloatInput(name="Depth min (km)", value=0., step=0.5)
114
+ xsec_zmax = pn.widgets.FloatInput(name="Depth max (km)", value=20., step=0.5)
115
+ xsec_zinc = pn.widgets.FloatInput(name="Depth step (km)", value=1., step=0.1, start=0.1)
116
+ xsec_npts = pn.widgets.IntInput(name="Points along line", value=50, step=5, start=5)
117
+ xsec_var = pn.widgets.Select(name="X-sec variable", options=VAR_OPTIONS, value="coulomb")
118
+ xsec_btn = pn.widgets.Button(name="▶ Compute Cross-Section", button_type="success",
119
+ sizing_mode="stretch_width")
120
+ xsec_cmap = pn.widgets.Select(name="Color Map", options=COLORSCALES, value="Jet", width=120)
121
+ xsec_vmin = pn.widgets.FloatInput(name="Min value", value=None, width=100)
122
+ xsec_vmax = pn.widgets.FloatInput(name="Max value", value=None, width=100)
123
+
124
+ xsec_pane = pn.pane.Plotly(go.Figure(), height=500, sizing_mode="stretch_width")
125
+ xsec_status = pn.pane.Markdown("")
126
+ xsec_download = pn.widgets.FileDownload(
127
+ label="⬇️ Download X-Sec Data (.csv)",
128
+ filename="cross_section.csv",
129
+ button_type="primary"
130
+ )
131
+
132
+ # Table
133
+ table_pane = pn.pane.DataFrame(pd.DataFrame(), sizing_mode="stretch_width",
134
+ max_rows=200, height=500)
135
+
136
+ coord_sys = pn.widgets.RadioBoxGroup(name="Coordinate System", options=["Local (km)", "Lat/Lon"], value="Local (km)", inline=True)
137
+ save_format = pn.widgets.Select(name="Save Format", options=["CSV", "SHP", "TIF"], value="CSV", width=100)
138
+ save_crs = pn.widgets.Select(name="Save Map CRS", options=["Local (km)", "Lat/Lon"], value="Local (km)", width=100)
139
+
140
+ map_download = pn.widgets.FileDownload(
141
+ label="⬇️ Download Map Data",
142
+ filename="coulomb_out.csv",
143
+ button_type="success"
144
+ )
145
+
146
+ # =====================================================================
147
+ # HELPERS
148
+ # =====================================================================
149
+ def _save_upload(widget, prefix=""):
150
+ if widget.value is None:
151
+ return None
152
+ path = os.path.join("cache", prefix + widget.filename)
153
+ with open(path, "wb") as f:
154
+ f.write(widget.value)
155
+ return path
156
+
157
+ def _set_status(msg, error=False):
158
+ color = "red" if error else "#4fc3f7"
159
+ status_md.object = f"**Status:** <span style='color:{color}'>{msg}</span>"
160
+
161
+ COL_MAP = {"ux": 5, "uy": 6, "uz": 7,
162
+ "sxx": 8, "syy": 9, "szz": 10,
163
+ "syz": 11, "sxz": 12, "sxy": 13}
164
+
165
+ def _get_vals(var, dc3d=None, shear=None, normal=None, coulomb=None):
166
+ """Return a 1-D array of the chosen variable from dc3d / stress arrays."""
167
+ if dc3d is None:
168
+ return None
169
+ if var in COL_MAP:
170
+ return dc3d[:, COL_MAP[var]]
171
+ elif var == "shear" and shear is not None:
172
+ return shear
173
+ elif var == "normal" and normal is not None:
174
+ return normal
175
+ elif var == "coulomb" and coulomb is not None:
176
+ return coulomb
177
+ return dc3d[:, 5] # fallback ux
178
+
179
+
180
+ def _draw_faults_2d(fig, el):
181
+ if el is None:
182
+ return
183
+ for i, f in enumerate(el):
184
+ fig.add_trace(go.Scatter(
185
+ x=[f[0], f[2]], y=[f[1], f[3]],
186
+ mode="lines+markers",
187
+ line=dict(color="red", width=3),
188
+ marker=dict(size=5, color="red"),
189
+ name=f"Fault {i}",
190
+ showlegend=(i == 0),
191
+ legendgroup="faults",
192
+ ))
193
+
194
+ def _draw_faults_3d(fig, el):
195
+ if el is None:
196
+ return
197
+ for i, f in enumerate(el):
198
+ xs, ys, xf, yf = f[0], f[1], f[2], f[3]
199
+ dip = f[6]; top, bot = f[7], f[8]
200
+ dip_rad = np.radians(dip)
201
+ h = (bot - top) / np.tan(dip_rad) if abs(dip) != 90 else 0
202
+ dx, dy = xf - xs, yf - ys
203
+ length = np.sqrt(dx**2 + dy**2)
204
+ if length == 0:
205
+ continue
206
+ nx_v, ny_v = dy / length, -dx / length
207
+ xsb, ysb = xs + nx_v * h, ys + ny_v * h
208
+ xfb, yfb = xf + nx_v * h, yf + ny_v * h
209
+ fig.add_trace(go.Scatter3d(
210
+ x=[xs, xf, xfb, xsb, xs],
211
+ y=[ys, yf, yfb, ysb, ys],
212
+ z=[top, top, bot, bot, top],
213
+ mode="lines",
214
+ line=dict(color="red", width=5),
215
+ name=f"Fault {i}",
216
+ showlegend=(i == 0),
217
+ legendgroup="faults",
218
+ ))
219
+
220
+ def km_to_deg(dx_km, dy_km, lon0, lat0):
221
+ """Convert local dx/dy (km) to lat/lon based on reference point."""
222
+ lat = lat0 + (dy_km / 111.32)
223
+ # 1 degree of longitude = 111.32 * cos(lat0) km
224
+ lon_deg_per_km = 1.0 / (111.32 * np.cos(np.radians(lat0))) if np.abs(lat0) < 89 else 0
225
+ lon = lon0 + (dx_km * lon_deg_per_km)
226
+ return lon, lat
227
+
228
+ def deg_to_km(lon, lat, lon0, lat0):
229
+ """Convert lat/lon to local dx/dy (km) based on reference point."""
230
+ dy_km = (lat - lat0) * 111.32
231
+ dx_km = (lon - lon0) * (111.32 * np.cos(np.radians(lat0)))
232
+ return dx_km, dy_km
233
+
234
+ def _on_coord_sys_change(event):
235
+ is_latlon = (event.new == "Lat/Lon")
236
+ map_info = STATE.get("map_info", {})
237
+ lon0 = map_info.get("zero_lon", 0.0)
238
+ lat0 = map_info.get("zero_lat", 0.0)
239
+
240
+ # Update widget names
241
+ unit = "°" if is_latlon else "km"
242
+ grid_xmin.name = f"X min ({unit})"
243
+ grid_xmax.name = f"X max ({unit})"
244
+ grid_ymin.name = f"Y min ({unit})"
245
+ grid_ymax.name = f"Y max ({unit})"
246
+ grid_inc.name = f"Spacing ({unit})"
247
+ xsec_ax.name = f"A → X ({unit})"
248
+ xsec_ay.name = f"A → Y ({unit})"
249
+ xsec_bx.name = f"B → X ({unit})"
250
+ xsec_by.name = f"B → Y ({unit})"
251
+ sp_x.name = f"X ({unit})"
252
+ sp_y.name = f"Y ({unit})"
253
+
254
+ # Simple conversion of current values
255
+ if lon0 != 0.0 or lat0 != 0.0:
256
+ if is_latlon:
257
+ # Convert km -> deg
258
+ grid_xmin.value, grid_ymin.value = km_to_deg(grid_xmin.value, grid_ymin.value, lon0, lat0)
259
+ grid_xmax.value, grid_ymax.value = km_to_deg(grid_xmax.value, grid_ymax.value, lon0, lat0)
260
+ grid_inc.value = grid_inc.value / 111.32 # Rough spacing
261
+ xsec_ax.value, xsec_ay.value = km_to_deg(xsec_ax.value, xsec_ay.value, lon0, lat0)
262
+ xsec_bx.value, xsec_by.value = km_to_deg(xsec_bx.value, xsec_by.value, lon0, lat0)
263
+ sp_x.value, sp_y.value = km_to_deg(sp_x.value, sp_y.value, lon0, lat0)
264
+ else:
265
+ # Convert deg -> km
266
+ grid_xmin.value, grid_ymin.value = deg_to_km(grid_xmin.value, grid_ymin.value, lon0, lat0)
267
+ grid_xmax.value, grid_ymax.value = deg_to_km(grid_xmax.value, grid_ymax.value, lon0, lat0)
268
+ grid_inc.value = grid_inc.value * 111.32
269
+ xsec_ax.value, xsec_ay.value = deg_to_km(xsec_ax.value, xsec_ay.value, lon0, lat0)
270
+ xsec_bx.value, xsec_by.value = deg_to_km(xsec_bx.value, xsec_by.value, lon0, lat0)
271
+ sp_x.value, sp_y.value = deg_to_km(sp_x.value, sp_y.value, lon0, lat0)
272
+
273
+ coord_sys.param.watch(_on_coord_sys_change, "value")
274
+
275
+ # =====================================================================
276
+ # CORE – parse source file on upload
277
+ # =====================================================================
278
+ def _on_file_upload(event):
279
+ path = _save_upload(file_input)
280
+ if path is None:
281
+ return
282
+ try:
283
+ xvec, yvec, z, el, kode, pois, young, cdepth, fric, _, map_info, cross_section = open_input_file_cui(path)
284
+ except Exception as exc:
285
+ import traceback; traceback.print_exc()
286
+ _set_status(f"Parse error: {exc}", error=True)
287
+ return
288
+
289
+ STATE.update(el=el, kode=kode, pois=pois, young=young,
290
+ fric=fric, cdepth=cdepth, xvec=xvec, yvec=yvec,
291
+ dc3d=None, shear=None, normal_stress=None, coulomb_stress=None,
292
+ map_info=map_info)
293
+
294
+ # Force Local (km) when new file loads
295
+ coord_sys.value = "Local (km)"
296
+ grid_xmin.name = "X min (km)"
297
+ grid_xmax.name = "X max (km)"
298
+ grid_ymin.name = "Y min (km)"
299
+ grid_ymax.name = "Y max (km)"
300
+ grid_inc.name = "Spacing (km)"
301
+
302
+ grid_xmin.value = float(xvec[0])
303
+ grid_xmax.value = float(xvec[-1])
304
+ grid_ymin.value = float(yvec[0])
305
+ grid_ymax.value = float(yvec[-1])
306
+ if len(xvec) > 1:
307
+ grid_inc.value = float(xvec[1] - xvec[0])
308
+ grid_depth.value = float(cdepth)
309
+
310
+ # Load default cross section if available
311
+ if cross_section:
312
+ if "start_x" in cross_section: xsec_ax.value = float(cross_section["start_x"])
313
+ if "start_y" in cross_section: xsec_ay.value = float(cross_section["start_y"])
314
+ if "finish_x" in cross_section: xsec_bx.value = float(cross_section["finish_x"])
315
+ if "finish_y" in cross_section: xsec_by.value = float(cross_section["finish_y"])
316
+
317
+ _set_status(f"Parsed {len(el)} fault segments from {file_input.filename}")
318
+ _refresh_map()
319
+
320
+ file_input.param.watch(_on_file_upload, "value")
321
+
322
+ # =====================================================================
323
+ # CORE – run calculation
324
+ # =====================================================================
325
+ def _on_run(event):
326
+ if STATE["el"] is None:
327
+ _set_status("Upload a source file first.", error=True)
328
+ return
329
+
330
+ mode = calc_mode.value
331
+ el = STATE["el"]
332
+ kode = STATE["kode"]
333
+ pois = STATE["pois"]
334
+ young = STATE["young"]
335
+ fric = STATE["fric"]
336
+
337
+ _set_status("Computing … please wait")
338
+
339
+ try:
340
+ is_latlon = (coord_sys.value == "Lat/Lon")
341
+ map_info = STATE.get("map_info") or {}
342
+ lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
343
+ lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
344
+
345
+ if mode in ("coulomb", "deformation"):
346
+ xvec_in = np.arange(grid_xmin.value,
347
+ grid_xmax.value + grid_inc.value * 0.5,
348
+ grid_inc.value)
349
+ yvec_in = np.arange(grid_ymin.value,
350
+ grid_ymax.value + grid_inc.value * 0.5,
351
+ grid_inc.value)
352
+ cdepth = grid_depth.value
353
+
354
+ if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
355
+ xvec_km, _ = deg_to_km(xvec_in, np.full_like(xvec_in, lat0), lon0, lat0)
356
+ _, yvec_km = deg_to_km(np.full_like(yvec_in, lon0), yvec_in, lon0, lat0)
357
+ xvec, yvec = xvec_km, yvec_km
358
+ else:
359
+ xvec, yvec = xvec_in, yvec_in
360
+
361
+ STATE["xvec"] = xvec
362
+ STATE["yvec"] = yvec
363
+ STATE["cdepth"] = cdepth
364
+ STATE["nx"] = len(xvec)
365
+ STATE["ny"] = len(yvec)
366
+
367
+ dc3d = okada_elastic_halfspace(xvec, yvec, el, young, pois, cdepth, kode)
368
+ STATE["dc3d"] = dc3d
369
+
370
+ if mode == "coulomb":
371
+ n = dc3d.shape[0]
372
+ ss = dc3d[:, 8:14].T
373
+ s, nt, c = calc_coulomb(
374
+ rec_strike.value * np.ones(n),
375
+ rec_dip.value * np.ones(n),
376
+ rec_rake.value * np.ones(n),
377
+ fric * np.ones(n), ss)
378
+ STATE["shear"] = s
379
+ STATE["normal_stress"] = nt
380
+ STATE["coulomb_stress"] = c
381
+ else:
382
+ STATE["shear"] = STATE["normal_stress"] = STATE["coulomb_stress"] = None
383
+
384
+ elif mode == "batch":
385
+ bpath = _save_upload(batch_input, prefix="batch_")
386
+ if bpath is None:
387
+ _set_status("Upload a batch file", error=True)
388
+ return
389
+ pos, strike_m, dip_m, rake_m = open_batch_file(bpath)
390
+ x_g, y_g, z_g = pos[:, 0], pos[:, 1], pos[:, 2]
391
+
392
+ if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
393
+ x_g, y_g = deg_to_km(x_g, y_g, lon0, lat0)
394
+
395
+ dc3d = okada_elastic_halfspace(x_g, y_g, el, young, pois, -z_g, kode)
396
+ STATE["dc3d"] = dc3d
397
+ STATE["nx"] = STATE["ny"] = 0
398
+ ss = dc3d[:, 8:14].T
399
+ s, nt, c = calc_coulomb(strike_m, dip_m, rake_m,
400
+ np.full(pos.shape[0], fric), ss)
401
+ STATE["shear"] = s
402
+ STATE["normal_stress"] = nt
403
+ STATE["coulomb_stress"] = c
404
+
405
+ elif mode == "single":
406
+ if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
407
+ x_km, y_km = deg_to_km(np.array([sp_x.value]), np.array([sp_y.value]), lon0, lat0)
408
+ x_g, y_g = x_km, y_km
409
+ else:
410
+ x_g = np.array([sp_x.value])
411
+ y_g = np.array([sp_y.value])
412
+ z_g = np.array([sp_z.value])
413
+ dc3d = okada_elastic_halfspace(x_g, y_g, el, young, pois, -z_g, kode)
414
+ STATE["dc3d"] = dc3d
415
+ STATE["nx"] = STATE["ny"] = 0
416
+ ss = dc3d[:, 8:14].T
417
+ s, nt, c = calc_coulomb(
418
+ np.array([sp_strike.value]),
419
+ np.array([sp_dip.value]),
420
+ np.array([sp_rake.value]),
421
+ np.full(1, fric), ss)
422
+ STATE["shear"] = s
423
+ STATE["normal_stress"] = nt
424
+ STATE["coulomb_stress"] = c
425
+
426
+ _set_status(f"Done – {dc3d.shape[0]} points computed")
427
+ _refresh_map()
428
+ _refresh_table()
429
+ _update_map_download()
430
+
431
+ except Exception as exc:
432
+ import traceback; traceback.print_exc()
433
+ _set_status(f"Error: {exc}", error=True)
434
+
435
+ run_btn.on_click(_on_run)
436
+
437
+ # =====================================================================
438
+ # MAP RENDER
439
+ # =====================================================================
440
+ def _refresh_map(*_):
441
+ fig = go.Figure()
442
+ dc3d = STATE.get("dc3d")
443
+ el = STATE.get("el")
444
+ is3d = toggle_3d.value
445
+ is_latlon = (coord_sys.value == "Lat/Lon")
446
+
447
+ map_info = STATE.get("map_info", {})
448
+ lon0 = map_info.get("zero_lon", 0.0)
449
+ lat0 = map_info.get("zero_lat", 0.0)
450
+
451
+ def _convert_pts(xs, ys):
452
+ if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
453
+ return km_to_deg(np.array(xs), np.array(ys), lon0, lat0)
454
+ return xs, ys
455
+
456
+ # ---- faults ----
457
+ if is3d:
458
+ if el is not None:
459
+ for i, f in enumerate(el):
460
+ xs, ys, xf, yf = f[0], f[1], f[2], f[3]
461
+ dip = f[6]; top, bot = f[7], f[8]
462
+ dip_rad = np.radians(dip)
463
+ h = (bot - top) / np.tan(dip_rad) if abs(dip) != 90 else 0
464
+ dx, dy = xf - xs, yf - ys
465
+ length = np.sqrt(dx**2 + dy**2)
466
+ if length > 0:
467
+ nx_v, ny_v = dy / length, -dx / length
468
+ xsb, ysb = xs + nx_v * h, ys + ny_v * h
469
+ xfb, yfb = xf + nx_v * h, yf + ny_v * h
470
+
471
+ px, py = _convert_pts([xs, xf, xfb, xsb, xs], [ys, yf, yfb, ysb, ys])
472
+ fig.add_trace(go.Scatter3d(
473
+ x=px, y=py, z=[top, top, bot, bot, top],
474
+ mode="lines", line=dict(color="red", width=5),
475
+ name=f"Fault {i}", showlegend=(i==0), legendgroup="faults"
476
+ ))
477
+ else:
478
+ if el is not None:
479
+ for i, f in enumerate(el):
480
+ px, py = _convert_pts([f[0], f[2]], [f[1], f[3]])
481
+ fig.add_trace(go.Scatter(
482
+ x=px, y=py, mode="lines+markers",
483
+ line=dict(color="red", width=3), marker=dict(size=5, color="red"),
484
+ name=f"Fault {i}", showlegend=(i==0), legendgroup="faults"
485
+ ))
486
+
487
+ # ---- computed data ----
488
+ if dc3d is not None:
489
+ vals = _get_vals(plot_var.value, dc3d,
490
+ STATE["shear"], STATE["normal_stress"],
491
+ STATE["coulomb_stress"])
492
+ nx, ny = STATE["nx"], STATE["ny"]
493
+ is_grid = nx > 1 and ny > 1
494
+
495
+ px, py = _convert_pts(dc3d[:, 0], dc3d[:, 1])
496
+
497
+ if is_grid and not is3d:
498
+ xvec = STATE["xvec"]
499
+ yvec = STATE["yvec"]
500
+ vx, vy = _convert_pts(xvec, yvec)
501
+
502
+ Z = np.array(vals).reshape(nx, ny).T # (ny, nx) for Plotly Heatmap
503
+
504
+ kwargs = {}
505
+ if map_vmin.value is not None: kwargs["zmin"] = map_vmin.value
506
+ if map_vmax.value is not None: kwargs["zmax"] = map_vmax.value
507
+
508
+ fig.add_trace(go.Heatmap(
509
+ x=vx, y=vy, z=Z,
510
+ colorscale=map_cmap.value,
511
+ colorbar=dict(title=plot_var.value.upper()),
512
+ **kwargs
513
+ ))
514
+ elif is3d:
515
+ marker_args = dict(size=3, color=vals, colorscale=map_cmap.value,
516
+ showscale=True, colorbar=dict(title=plot_var.value.upper()))
517
+ if map_vmin.value is not None: marker_args["cmin"] = map_vmin.value
518
+ if map_vmax.value is not None: marker_args["cmax"] = map_vmax.value
519
+
520
+ fig.add_trace(go.Scatter3d(
521
+ x=px, y=py, z=-dc3d[:, 4],
522
+ mode="markers",
523
+ marker=marker_args,
524
+ name=plot_var.value,
525
+ ))
526
+ else:
527
+ marker_args = dict(size=6, color=vals, colorscale=map_cmap.value,
528
+ showscale=True, colorbar=dict(title=plot_var.value.upper()))
529
+ if map_vmin.value is not None: marker_args["cmin"] = map_vmin.value
530
+ if map_vmax.value is not None: marker_args["cmax"] = map_vmax.value
531
+
532
+ fig.add_trace(go.Scatter(
533
+ x=px, y=py,
534
+ mode="markers",
535
+ marker=marker_args,
536
+ name=plot_var.value,
537
+ ))
538
+
539
+ # ---- layout ----
540
+ unit_str = "°" if is_latlon else "km"
541
+ xlabel = "Lon (°)" if is_latlon else "X (km)"
542
+ ylabel = "Lat (°)" if is_latlon else "Y (km)"
543
+
544
+ if is3d:
545
+ fig.update_layout(scene=dict(
546
+ xaxis_title=xlabel, yaxis_title=ylabel, zaxis_title="Depth (km)",
547
+ zaxis=dict(autorange="reversed"),
548
+ ))
549
+ else:
550
+ fig.update_layout(
551
+ xaxis_title=xlabel, yaxis_title=ylabel,
552
+ xaxis=dict(autorange=True, constrain="domain"),
553
+ yaxis=dict(autorange=True, constrain="domain"),
554
+ clickmode="event+select"
555
+ )
556
+
557
+ # ---- invisible click-catcher so clicks work anywhere on map ----
558
+ has_grid = (dc3d is not None
559
+ and STATE.get("nx", 0) > 1
560
+ and STATE.get("ny", 0) > 1
561
+ and not is3d)
562
+ if not is3d and not has_grid:
563
+ xmin = grid_xmin.value or -50
564
+ xmax = grid_xmax.value or 50
565
+ ymin = grid_ymin.value or -50
566
+ ymax = grid_ymax.value or 50
567
+ nclick = 51
568
+
569
+ cx = np.linspace(xmin, xmax, nclick)
570
+ cy = np.linspace(ymin, ymax, nclick)
571
+ gx, gy = [], []
572
+ for xi in cx:
573
+ for yi in cy:
574
+ gx.append(xi)
575
+ gy.append(yi)
576
+ fig.add_trace(go.Scatter(
577
+ x=gx, y=gy,
578
+ mode="markers",
579
+ marker=dict(size=1, opacity=0),
580
+ hoverinfo="x+y",
581
+ showlegend=False,
582
+ name="_click_catcher",
583
+ ))
584
+
585
+ # ---- X-section line ----
586
+ if not is3d and xsec_ax.value is not None:
587
+ fig.add_trace(go.Scatter(
588
+ x=[xsec_ax.value, xsec_bx.value], y=[xsec_ay.value, xsec_by.value],
589
+ mode="lines+markers+text",
590
+ line=dict(color="lime", width=3, dash="dash"),
591
+ marker=dict(size=10, color="lime"),
592
+ text=["A", "B"], textposition="top center",
593
+ textfont=dict(color="lime", size=14),
594
+ name="X-Section line",
595
+ hoverinfo="x+y+text"
596
+ ))
597
+
598
+ fig.update_layout(
599
+ margin=dict(l=0, r=0, t=30, b=0),
600
+ template="plotly_dark",
601
+ height=650,
602
+ )
603
+ # FIX 2: replace the entire Plotly pane object to avoid dirty 3D scene state
604
+ map_pane.object = fig
605
+
606
+ plot_var.param.watch(_refresh_map, "value")
607
+ map_cmap.param.watch(_refresh_map, "value")
608
+ map_vmin.param.watch(_refresh_map, "value")
609
+ map_vmax.param.watch(_refresh_map, "value")
610
+ toggle_3d.param.watch(_refresh_map, "value")
611
+ refresh_btn.on_click(_refresh_map)
612
+
613
+ # --- Interactive Map Clicking ---
614
+ _click_lock = {"active": False}
615
+
616
+ def _on_map_click(event):
617
+ if _click_lock["active"]:
618
+ return
619
+ if event.new is None or not isinstance(event.new, dict):
620
+ return
621
+ points = event.new.get("points", [])
622
+ if not points:
623
+ return
624
+
625
+ # Only allow 2D map clicks for cross section
626
+ if toggle_3d.value:
627
+ return
628
+
629
+ _click_lock["active"] = True
630
+ try:
631
+ pt = points[0]
632
+ cx = float(pt.get("x", 0))
633
+ cy = float(pt.get("y", 0))
634
+ if STATE["click_state"] == "A":
635
+ xsec_ax.value = round(cx, 2)
636
+ xsec_ay.value = round(cy, 2)
637
+ STATE["click_state"] = "B"
638
+ _set_status(f"Point A set to ({cx:.2f}, {cy:.2f}). Click map for Point B.")
639
+ else:
640
+ xsec_bx.value = round(cx, 2)
641
+ xsec_by.value = round(cy, 2)
642
+ STATE["click_state"] = "A"
643
+ _set_status(f"Point B set to ({cx:.2f}, {cy:.2f}). Ready to compute.")
644
+ # Single refresh after both coords updated
645
+ _refresh_map()
646
+ finally:
647
+ _click_lock["active"] = False
648
+
649
+ map_pane.param.watch(_on_map_click, "click_data")
650
+
651
+ # =====================================================================
652
+ # CROSS-SECTION (depth-vs-distance with recalculation)
653
+
654
+ # =====================================================================
655
+ def _on_xsec(event):
656
+ """
657
+ 1. Sample `npts` points along the line A→B.
658
+ 2. For each depth in [zmin, zmax, step], run okada + coulomb.
659
+ 3. Plot heatmap: X = distance along profile, Y = depth.
660
+ """
661
+ if STATE["el"] is None:
662
+ xsec_status.object = "**Upload source file first.**"
663
+ return
664
+
665
+ ax, ay = xsec_ax.value, xsec_ay.value
666
+ bx, by = xsec_bx.value, xsec_by.value
667
+ zmin = xsec_zmin.value
668
+ zmax = xsec_zmax.value
669
+ zinc = xsec_zinc.value
670
+ npts = xsec_npts.value
671
+ var = xsec_var.value
672
+
673
+ el = STATE["el"]
674
+ kode = STATE["kode"]
675
+ is_latlon = (coord_sys.value == "Lat/Lon")
676
+ map_info = STATE.get("map_info") or {}
677
+ lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
678
+ lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
679
+
680
+ ax, ay = xsec_ax.value, xsec_ay.value
681
+ bx, by = xsec_bx.value, xsec_by.value
682
+
683
+ if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
684
+ ax, ay = deg_to_km(ax, ay, lon0, lat0)
685
+ bx, by = deg_to_km(bx, by, lon0, lat0)
686
+
687
+ t = np.linspace(0, 1, npts)
688
+ px = ax + (bx - ax) * t
689
+ py = ay + (by - ay) * t
690
+ pois = STATE["pois"]
691
+ young = STATE["young"]
692
+ fric = STATE["fric"]
693
+
694
+ # Profile points
695
+ dist = np.sqrt((px - ax)**2 + (py - ay)**2) # distance along profile
696
+
697
+ depths = np.arange(zmin, zmax + zinc * 0.5, zinc)
698
+ ndepth = len(depths)
699
+
700
+ xsec_status.object = f"Computing {npts} × {ndepth} = **{npts * ndepth}** points …"
701
+
702
+ try:
703
+ grid_vals = np.zeros((ndepth, npts))
704
+
705
+ for iz, zval in enumerate(depths):
706
+ cdepth_arr = np.full(npts, zval)
707
+ dc3d = okada_elastic_halfspace(px, py, el, young, pois, cdepth_arr, kode)
708
+ dc_arr = np.array(dc3d)
709
+
710
+ if var in ("shear", "normal", "coulomb"):
711
+ ss = dc_arr[:, 8:14].T
712
+ s, nt, c = calc_coulomb(
713
+ rec_strike.value * np.ones(npts),
714
+ rec_dip.value * np.ones(npts),
715
+ rec_rake.value * np.ones(npts),
716
+ fric * np.ones(npts), ss)
717
+ if var == "shear":
718
+ grid_vals[iz, :] = s
719
+ elif var == "normal":
720
+ grid_vals[iz, :] = nt
721
+ else:
722
+ grid_vals[iz, :] = c
723
+ else:
724
+ grid_vals[iz, :] = _get_vals(var, dc_arr)
725
+
726
+ kwargs = {}
727
+ if xsec_vmin.value is not None: kwargs["zmin"] = xsec_vmin.value
728
+ if xsec_vmax.value is not None: kwargs["zmax"] = xsec_vmax.value
729
+
730
+ fig = go.Figure(go.Heatmap(
731
+ x=dist,
732
+ y=depths,
733
+ z=grid_vals,
734
+ colorscale=xsec_cmap.value,
735
+ colorbar=dict(title=var.upper()),
736
+ **kwargs
737
+ ))
738
+ fig.update_layout(
739
+ title=f"Cross-Section A({ax},{ay}) → B({bx},{by})",
740
+ xaxis_title="Distance along profile (km)",
741
+ yaxis_title="Depth (km)",
742
+ yaxis=dict(autorange="reversed"),
743
+ template="plotly_dark",
744
+ height=500,
745
+ margin=dict(l=50, r=20, t=50, b=50),
746
+ clickmode="none"
747
+ )
748
+
749
+ xsec_pane.object = fig
750
+ xsec_status.object = f"✅ Done – {npts * ndepth} points"
751
+
752
+ # Save X-sec dataframe for download
753
+ rows = []
754
+
755
+ # We also want to compute exact spatial coordinates for export
756
+ # If the map is using Lat/Lon we might already have the zero reference converted
757
+ map_info = STATE.get("map_info") or {}
758
+ lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
759
+ lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
760
+
761
+ has_deg_ref = (lon0 != 0.0 or lat0 != 0.0)
762
+
763
+ if has_deg_ref:
764
+ lon_arr, lat_arr = km_to_deg(px, py, lon0, lat0)
765
+
766
+ # For uniform format, we calculate shear/normal/coulomb for EVERY cross-section point
767
+ # instead of just the plotted `var`.
768
+ for iz, zval in enumerate(depths):
769
+ # Recalculate dc3d to grab raw tensors directly for CSV export
770
+ cdepth_arr = np.full(npts, zval)
771
+ dc_arr = np.array(okada_elastic_halfspace(px, py, el, young, pois, cdepth_arr, kode))
772
+ ss = dc_arr[:, 8:14].T
773
+ s, nt, c = calc_coulomb(
774
+ rec_strike.value * np.ones(npts),
775
+ rec_dip.value * np.ones(npts),
776
+ rec_rake.value * np.ones(npts),
777
+ fric * np.ones(npts), ss)
778
+
779
+ for ip in range(npts):
780
+ r = {
781
+ "X_km": px[ip],
782
+ "Y_km": py[ip],
783
+ "Z_km": dc_arr[ip, 4],
784
+ }
785
+
786
+ if has_deg_ref:
787
+ r["Lon"] = lon_arr[ip]
788
+ r["Lat"] = lat_arr[ip]
789
+
790
+ r.update({
791
+ "ux_m": dc_arr[ip, 5],
792
+ "uy_m": dc_arr[ip, 6],
793
+ "uz_m": dc_arr[ip, 7],
794
+ "sxx_bar": dc_arr[ip, 8],
795
+ "syy_bar": dc_arr[ip, 9],
796
+ "szz_bar": dc_arr[ip, 10],
797
+ "syz_bar": dc_arr[ip, 11],
798
+ "sxz_bar": dc_arr[ip, 12],
799
+ "sxy_bar": dc_arr[ip, 13],
800
+ "Shear_bar": s[ip],
801
+ "Normal_bar": nt[ip],
802
+ "Coulomb_bar": c[ip],
803
+ })
804
+ rows.append(r)
805
+
806
+ STATE["xsec_data"] = pd.DataFrame(rows)
807
+ _update_xsec_download()
808
+
809
+ except Exception as exc:
810
+ import traceback; traceback.print_exc()
811
+ xsec_status.object = f"**Error:** {exc}"
812
+
813
+ xsec_btn.on_click(_on_xsec)
814
+
815
+ # =====================================================================
816
+ # TABLE
817
+ # =====================================================================
818
+ def _refresh_table(*_):
819
+ dc3d = STATE["dc3d"]
820
+ if dc3d is None:
821
+ return
822
+
823
+ dc_arr = np.array(dc3d)
824
+ d = {
825
+ "X_km": dc_arr[:, 0], "Y_km": dc_arr[:, 1], "Z_km": dc_arr[:, 4],
826
+ }
827
+
828
+ map_info = STATE.get("map_info") or {}
829
+ lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
830
+ lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
831
+
832
+ if lon0 != 0.0 or lat0 != 0.0:
833
+ lon, lat = km_to_deg(dc_arr[:, 0], dc_arr[:, 1], lon0, lat0)
834
+ d["Lon"] = lon
835
+ d["Lat"] = lat
836
+
837
+ d.update({
838
+ "ux_m": dc_arr[:, 5], "uy_m": dc_arr[:, 6], "uz_m": dc_arr[:, 7],
839
+ "sxx_bar": dc_arr[:, 8], "syy_bar": dc_arr[:, 9], "szz_bar": dc_arr[:, 10],
840
+ "syz_bar": dc_arr[:, 11], "sxz_bar": dc_arr[:, 12], "sxy_bar": dc_arr[:, 13],
841
+ })
842
+
843
+ if STATE.get("shear") is not None:
844
+ d["Shear_bar"] = STATE["shear"]
845
+ d["Normal_bar"] = STATE["normal_stress"]
846
+ d["Coulomb_bar"] = STATE["coulomb_stress"]
847
+
848
+ table_pane.object = pd.DataFrame(d)
849
+
850
+ # =====================================================================
851
+ # SAVE FILE → FILE DOWNLOAD
852
+ # =====================================================================
853
+ def _update_map_download(*_):
854
+ df = table_pane.object
855
+ if df is None or not isinstance(df, pd.DataFrame) or df.empty:
856
+ map_download.file = None
857
+ return
858
+
859
+ mode = calc_mode.value
860
+ fmt = save_format.value
861
+ save_in_latlon = (save_crs.value == "Lat/Lon")
862
+
863
+ nx, ny = STATE.get("nx", 0), STATE.get("ny", 0)
864
+
865
+ # Common variables
866
+ base_name = "coulomb_out" if mode in ("coulomb", "batch", "single") else "halfspace_def_out"
867
+ ext = fmt.lower()
868
+ fname = f"{base_name}.{ext}"
869
+ map_download.filename = fname
870
+
871
+ # --- EXPORT LOGIC ---
872
+ if fmt == "CSV":
873
+ # Save CSV to memory
874
+ buf = io.StringIO()
875
+ df.to_csv(buf, index=False)
876
+ buf.seek(0)
877
+ map_download.file = buf
878
+ return
879
+
880
+ # Use a temporary file for rasterio and geopandas since they need actual paths or complex file-like objects
881
+ tmp_path = os.path.join("cache", fname)
882
+
883
+ if fmt == "SHP":
884
+ # Save Shapefile
885
+ if save_in_latlon and "Lon" in df.columns:
886
+ gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.Lon, df.Lat), crs="EPSG:4326")
887
+ else:
888
+ gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.X_km, df.Y_km)) # Local CRS
889
+
890
+ # Shapefile involves multiple files (.shp, .shx, .dbf, etc).
891
+ # We will create a zip file for the shapefile.
892
+ import zipfile
893
+ zip_fname = f"{base_name}.zip"
894
+ zip_path = os.path.join("cache", zip_fname)
895
+
896
+ # Save shapefile parts to cache dir
897
+ shp_path = os.path.join("cache", fname)
898
+ gdf.to_file(shp_path)
899
+
900
+ # Zip them up
901
+ with zipfile.ZipFile(zip_path, 'w') as zipf:
902
+ for ext_name in ['.shp', '.shx', '.dbf', '.prj', '.cpg']:
903
+ p = os.path.join("cache", f"{base_name}{ext_name}")
904
+ if os.path.exists(p):
905
+ zipf.write(p, arcname=f"{base_name}{ext_name}")
906
+
907
+ # Serve the zip
908
+ map_download.filename = zip_fname
909
+ map_download.file = os.path.abspath(zip_path)
910
+
911
+ elif fmt == "TIF":
912
+ nx, ny = STATE.get("nx", 0), STATE.get("ny", 0)
913
+ if nx <= 1 or ny <= 1:
914
+ _set_status("TIF export only supports Grid calculations.", error=True)
915
+ map_download.file = None
916
+ return
917
+
918
+ # Get target variable
919
+ vals = _get_vals(plot_var.value, STATE.get("dc3d"), STATE.get("shear"), STATE.get("normal_stress"), STATE.get("coulomb_stress"))
920
+ if vals is None:
921
+ return
922
+
923
+ grid_data = np.array(vals).reshape(nx, ny).T.astype(np.float32) # Reshape to (nx, ny) then Transpose to (ny, nx) for image coordinates
924
+
925
+ if save_in_latlon and "Lon" in df.columns:
926
+ xmin = df["Lon"].min()
927
+ xmax = df["Lon"].max()
928
+ ymin = df["Lat"].min()
929
+ ymax = df["Lat"].max()
930
+ crs = "EPSG:4326"
931
+ else:
932
+ xmin = df["X_km"].min()
933
+ xmax = df["X_km"].max()
934
+ ymin = df["Y_km"].min()
935
+ ymax = df["Y_km"].max()
936
+ crs = None
937
+
938
+ # When we write to a Raster using `nx, ny`, the raster takes the absolute bounding box.
939
+ # Since df["Lon"] max/min are point centers, adding half-pixel allows the edge to align.
940
+ # However, due to QGIS coordinate logic on degrees, we must be precise.
941
+ # Actually, let's just use from_bounds without manual half-pixel pad. Rasterio handles it.
942
+ # Wait, from_bounds(west, south, east, north)
943
+
944
+ # In QGIS, if we use from_bounds(xmin, ymin, xmax, ymax) it considers the corners as the centers.
945
+ # In QGIS, if we use from_bounds(xmin, ymin, xmax, ymax) it considers the corners as the centers.
946
+ transform = from_bounds(xmin, ymin, xmax, ymax, nx, ny)
947
+
948
+ with rasterio.open(
949
+ tmp_path, 'w', driver='GTiff',
950
+ height=grid_data.shape[0], width=grid_data.shape[1],
951
+ count=1, dtype=str(grid_data.dtype),
952
+ crs=crs, transform=transform,
953
+ ) as dst:
954
+ dst.write(np.flipud(grid_data), 1)
955
+
956
+ map_download.file = os.path.abspath(tmp_path)
957
+
958
+ save_format.param.watch(_update_map_download, 'value')
959
+ save_crs.param.watch(_update_map_download, 'value')
960
+
961
+ def _update_xsec_download():
962
+ df = STATE.get("xsec_data", None)
963
+ if df is None or df.empty:
964
+ xsec_download.file = None
965
+ return
966
+
967
+ buf = io.StringIO()
968
+ df.to_csv(buf, index=False)
969
+ buf.seek(0)
970
+ xsec_download.file = buf
971
+
972
+ # =====================================================================
973
+ # LAYOUT
974
+
975
+ # =====================================================================
976
+ grid_box = pn.Column(
977
+ "### Grid Limits",
978
+ pn.Row(grid_xmin, grid_xmax),
979
+ pn.Row(grid_ymin, grid_ymax),
980
+ pn.Row(grid_inc, grid_depth),
981
+ )
982
+ receiver_box = pn.Column(
983
+ "### Receiver Angles",
984
+ pn.Row(rec_strike, rec_dip, rec_rake),
985
+ )
986
+ batch_box = pn.Column(
987
+ "### Batch Target File",
988
+ batch_input,
989
+ )
990
+ single_box = pn.Column(
991
+ "### Target Coordinates",
992
+ pn.Row(sp_x, sp_y, sp_z),
993
+ "### Target Fault Angles",
994
+ pn.Row(sp_strike, sp_dip, sp_rake),
995
+ )
996
+
997
+ @pn.depends(calc_mode.param.value)
998
+ def _mode_panel(mode):
999
+ if mode in ("coulomb", "deformation"):
1000
+ return pn.Column(grid_box, receiver_box)
1001
+ elif mode == "batch":
1002
+ return pn.Column(batch_box, pn.pane.Markdown("*Receiver angles read from file.*"))
1003
+ elif mode == "single":
1004
+ return single_box
1005
+ return pn.Column()
1006
+
1007
+ sidebar = pn.Column(
1008
+ "# ⚡ Coulomb Stress",
1009
+ pn.layout.Divider(),
1010
+ file_input,
1011
+ calc_mode,
1012
+ coord_sys,
1013
+ _mode_panel,
1014
+ pn.layout.Divider(),
1015
+ run_btn,
1016
+ status_md,
1017
+ width=360,
1018
+ sizing_mode="stretch_height",
1019
+ )
1020
+
1021
+ # Move cross-section to map tab
1022
+ map_view_tools = pn.Column(
1023
+ pn.Row(plot_var, map_cmap, map_vmin, map_vmax),
1024
+ pn.Row(toggle_3d, refresh_btn, save_format, save_crs, map_download),
1025
+ pn.layout.Divider(),
1026
+ "**Define Profile Line A → B**",
1027
+ pn.Row(xsec_ax, xsec_ay, pn.pane.Markdown("**→**", margin=(10, 10)), xsec_bx, xsec_by),
1028
+ )
1029
+
1030
+ # Cross-section tab contents (only keep depth, points, computation)
1031
+ xsec_tab = pn.Column(
1032
+ "### Profile configurations",
1033
+ pn.Row(xsec_zmin, xsec_zmax, xsec_zinc),
1034
+ pn.Row(xsec_npts, xsec_var),
1035
+ pn.Row(xsec_cmap, xsec_vmin, xsec_vmax),
1036
+ xsec_btn,
1037
+ xsec_status,
1038
+ pn.Row(xsec_download),
1039
+ xsec_pane,
1040
+ )
1041
+
1042
+ main_tabs = pn.Tabs(
1043
+ ("2D / 3D Map", pn.Column(map_view_tools, map_pane)),
1044
+ ("Cross Section View", xsec_tab),
1045
+ ("Table Preview", pn.Column(table_pane)),
1046
+ dynamic=True,
1047
+ )
1048
+
1049
+ template = pn.template.FastListTemplate(
1050
+ title="Coulomb Stress Change Dashboard",
1051
+ sidebar=[sidebar],
1052
+ main=[main_tabs],
1053
+ accent_base_color="#4fc3f7",
1054
+ header_background="#1e1e2e",
1055
+ theme="dark",
1056
+ )
1057
+
1058
+ template.servable()