josephsoo commited on
Commit
c16b1d0
·
1 Parent(s): 6e6636e

Link neural activity to target playback

Browse files
Files changed (3) hide show
  1. app.py +203 -56
  2. assets/dataset_link.js +340 -0
  3. assets/styles.css +165 -0
app.py CHANGED
@@ -893,6 +893,7 @@ def dataset_cards(selected_dataset: str) -> list[html.Article]:
893
  def target_space_graph(figure: go.Figure, label: str) -> html.Div:
894
  return html.Div(
895
  dcc.Graph(
 
896
  figure=figure,
897
  config={"displaylogo": False, "responsive": True},
898
  ),
@@ -1002,10 +1003,85 @@ def separated_trajectory_values(
1002
  return x_values, y_values
1003
 
1004
 
1005
- def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1006
  for column in ["trial_index", "condition_id", "time_index", "time_ms", "target_0", "target_1"]:
1007
  frame[column] = pd.to_numeric(frame[column], errors="coerce")
1008
- example = frame[frame["is_example"]].sort_values("time_index")
 
 
 
 
 
1009
  figure = go.Figure()
1010
  legend_items: list[tuple[str, str]] = []
1011
 
@@ -1026,16 +1102,18 @@ def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
1026
  )
1027
  )
1028
  legend_items.append((condition_label(dataset, condition_id), color))
1029
- figure.add_trace(
1030
- go.Scattergl(
1031
- x=example["target_0"],
1032
- y=example["target_1"],
1033
- mode="lines",
1034
- line=dict(color="#102A3A", width=4),
1035
- customdata=example["time_ms"],
1036
- hovertemplate="Horizontal position=%{x:.3f}<br>Vertical position=%{y:.3f}<br>Time=%{customdata:.0f} ms<extra>Example</extra>",
1037
- showlegend=False,
1038
- )
 
 
1039
  )
1040
  figure.update_xaxes(title="Horizontal hand position")
1041
  figure.update_yaxes(title="Vertical hand position", scaleanchor="x", scaleratio=1)
@@ -1069,15 +1147,14 @@ def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
1069
  )
1070
  )
1071
  legend_items.append((MC_PROFILE_LABELS[condition_id], color))
1072
- figure.add_trace(
1073
- go.Scatter(
1074
- x=example["time_ms"],
1075
- y=example["target_0"],
1076
- mode="lines",
1077
- line=dict(color="#102A3A", width=3.5),
1078
- hovertemplate="Time=%{x:.0f} ms<br>Force=%{y:.3f}<extra>Example</extra>",
1079
- showlegend=False,
1080
- )
1081
  )
1082
  figure.add_vline(x=0, line_color="#71808D", line_dash="dash")
1083
  figure.update_xaxes(title="Time from scoring onset (ms)")
@@ -1096,26 +1173,18 @@ def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
1096
  hovertemplate="x=%{x:.3f}<br>y=%{y:.3f}<br>Samples=%{z}<extra></extra>",
1097
  )
1098
  )
1099
- figure.add_trace(
1100
- go.Scatter(
1101
- x=example["target_0"],
1102
- y=example["target_1"],
1103
- mode="lines",
1104
- line=dict(color="#FFFFFF", width=5),
1105
- hoverinfo="skip",
1106
- showlegend=False,
1107
- )
1108
- )
1109
- figure.add_trace(
1110
- go.Scatter(
1111
- x=example["target_0"],
1112
- y=example["target_1"],
1113
- mode="lines",
1114
- line=dict(color="#102A3A", width=2.5),
1115
- customdata=example["time_ms"],
1116
- hovertemplate="x=%{x:.3f}<br>y=%{y:.3f}<br>Time=%{customdata:.0f} ms<extra>Example</extra>",
1117
- showlegend=False,
1118
- )
1119
  )
1120
  figure.update_xaxes(title="x position", range=[0, 1])
1121
  figure.update_yaxes(title="y position", range=[0, 1], scaleanchor="x", scaleratio=1)
@@ -1123,7 +1192,12 @@ def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
1123
  aria_label = "All simulated position targets shown as spatial occupancy density."
1124
 
1125
  figure_layout(figure, height=410)
1126
- figure.update_layout(margin=dict(l=62, r=24, t=18, b=62), showlegend=False)
 
 
 
 
 
1127
  return html.Div(
1128
  [
1129
  html.Div(
@@ -1137,13 +1211,43 @@ def target_trajectory_space(dataset: str, frame: pd.DataFrame) -> html.Div:
1137
  )
1138
 
1139
 
1140
- def dataset_example_figures(dataset: str) -> tuple[go.Figure, html.Div, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
  metadata = dataset_overview[dataset_overview["dataset"].astype(str).eq(dataset)].iloc[0]
1142
  neural = dataset_example_neural[
1143
  dataset_example_neural["dataset"].astype(str).eq(dataset)
1144
  ].copy()
1145
  targets = dataset_targets[dataset_targets["dataset"].astype(str).eq(dataset)].copy()
1146
  targets["is_example"] = targets["is_example"].astype(str).str.lower().eq("true")
 
 
 
1147
  for column in (
1148
  "time_index",
1149
  "time_ms",
@@ -1168,7 +1272,13 @@ def dataset_example_figures(dataset: str) -> tuple[go.Figure, html.Div, str]:
1168
  .sort_values("feature_display_index")["feature_index"]
1169
  .to_numpy(dtype=int)
1170
  )
1171
- customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
 
 
 
 
 
 
1172
  upper = max(int(np.ceil(np.nanmax(values.to_numpy(dtype=float)))), 1)
1173
  if upper <= 4:
1174
  count_ticks = list(range(upper + 1))
@@ -1193,21 +1303,25 @@ def dataset_example_figures(dataset: str) -> tuple[go.Figure, html.Div, str]:
1193
  ticktext=[str(value) for value in count_ticks],
1194
  ),
1195
  hovertemplate=(
1196
- "Feature=%{customdata}<br>Time=%{x:.0f} ms<br>"
1197
  "Count=%{z:.0f}<extra></extra>"
1198
  ),
1199
  )
1200
  )
1201
- neural_figure.add_vline(x=0, line_color="#D55E00", line_dash="dash")
1202
  neural_figure.update_xaxes(title="Time from scoring onset (ms)")
1203
  neural_figure.update_yaxes(title="Neural features", showticklabels=False)
1204
  figure_layout(neural_figure, height=470)
1205
- neural_figure.update_layout(margin=dict(l=58, r=62, t=18, b=62))
 
 
 
 
1206
 
1207
  if dataset in {"allen_neuropixels", "speech"}:
1208
  target_component = target_class_space(dataset, targets)
1209
  else:
1210
- target_component = target_trajectory_space(dataset, targets)
1211
 
1212
  shown = int(metadata.example_features_shown)
1213
  total = int(metadata.array_shape.split("×")[-1].strip())
@@ -1216,11 +1330,19 @@ def dataset_example_figures(dataset: str) -> tuple[go.Figure, html.Div, str]:
1216
  if shown == total
1217
  else f"{shown} of {total} neural features are shown for legibility"
1218
  )
1219
- description = (
1220
- f"The highlighted target corresponds to the neural activity shown at left; "
1221
- f"{feature_text}."
 
 
 
 
 
 
 
 
 
1222
  )
1223
- return neural_figure, target_component, description
1224
 
1225
 
1226
  def overview_cards(dataset: str, models: Sequence[str] | None) -> list[html.Div]:
@@ -2758,6 +2880,11 @@ app.layout = html.Div(
2758
  ),
2759
  panel(
2760
  "Neural activity and task targets",
 
 
 
 
 
2761
  html.Div(
2762
  [
2763
  html.Div(
@@ -2774,11 +2901,15 @@ app.layout = html.Div(
2774
  "Example trial neural activity for the selected dataset.",
2775
  ),
2776
  ],
2777
- className="dataset-example-card",
 
 
 
 
2778
  ),
2779
  html.Div(
2780
  id="dataset-target-space",
2781
- className="dataset-example-card",
2782
  ),
2783
  ],
2784
  className="dataset-example-grid",
@@ -3106,12 +3237,28 @@ app.clientside_callback(
3106
  Output("dataset-neural-example", "figure"),
3107
  Output("dataset-target-space", "children"),
3108
  Output("dataset-example-description", "children"),
 
3109
  Input("dataset-filter", "value"),
3110
  )
3111
  def update_dataset_examples(dataset: str):
3112
  dataset = dataset or DATASETS[0]
3113
- neural, target, description = dataset_example_figures(dataset)
3114
- return dataset_cards(dataset), neural, target, description
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3115
 
3116
 
3117
  @app.callback(
 
893
  def target_space_graph(figure: go.Figure, label: str) -> html.Div:
894
  return html.Div(
895
  dcc.Graph(
896
+ id="dataset-target-trajectory",
897
  figure=figure,
898
  config={"displaylogo": False, "responsive": True},
899
  ),
 
1003
  return x_values, y_values
1004
 
1005
 
1006
+ def add_linked_target_traces(
1007
+ figure: go.Figure,
1008
+ x_values: Sequence[float],
1009
+ y_values: Sequence[float],
1010
+ customdata: np.ndarray,
1011
+ hovertemplate: str,
1012
+ *,
1013
+ base_width: float,
1014
+ progress_width: float,
1015
+ halo_width: float | None = None,
1016
+ ) -> None:
1017
+ x_values = list(x_values)
1018
+ y_values = list(y_values)
1019
+ if halo_width is not None:
1020
+ figure.add_trace(
1021
+ go.Scatter(
1022
+ x=x_values,
1023
+ y=y_values,
1024
+ mode="lines",
1025
+ line=dict(color="#FFFFFF", width=halo_width),
1026
+ opacity=0.9,
1027
+ hoverinfo="skip",
1028
+ showlegend=False,
1029
+ )
1030
+ )
1031
+ figure.add_trace(
1032
+ go.Scatter(
1033
+ x=x_values,
1034
+ y=y_values,
1035
+ mode="lines",
1036
+ line=dict(color="#102A3A", width=base_width),
1037
+ opacity=0.34,
1038
+ customdata=customdata,
1039
+ meta={"benchdash_role": "example_trajectory"},
1040
+ hovertemplate=hovertemplate,
1041
+ showlegend=False,
1042
+ )
1043
+ )
1044
+ figure.add_trace(
1045
+ go.Scatter(
1046
+ x=[x_values[0]],
1047
+ y=[y_values[0]],
1048
+ mode="lines",
1049
+ line=dict(color="#102A3A", width=progress_width),
1050
+ hoverinfo="skip",
1051
+ meta={"benchdash_role": "linked_target_progress"},
1052
+ showlegend=False,
1053
+ )
1054
+ )
1055
+ figure.add_trace(
1056
+ go.Scatter(
1057
+ x=[x_values[0]],
1058
+ y=[y_values[0]],
1059
+ mode="markers",
1060
+ marker=dict(
1061
+ size=14,
1062
+ color="#D55E00",
1063
+ line=dict(color="#FFFFFF", width=3),
1064
+ ),
1065
+ hoverinfo="skip",
1066
+ meta={"benchdash_role": "linked_target_cursor"},
1067
+ showlegend=False,
1068
+ )
1069
+ )
1070
+
1071
+
1072
+ def target_trajectory_space(
1073
+ dataset: str,
1074
+ frame: pd.DataFrame,
1075
+ example: pd.DataFrame,
1076
+ ) -> html.Div:
1077
  for column in ["trial_index", "condition_id", "time_index", "time_ms", "target_0", "target_1"]:
1078
  frame[column] = pd.to_numeric(frame[column], errors="coerce")
1079
+ for column in ["time_index", "time_ms", "target_0", "target_1"]:
1080
+ example[column] = pd.to_numeric(example[column], errors="coerce")
1081
+ example = example.sort_values("time_index")
1082
+ example_customdata = np.column_stack(
1083
+ [example["time_ms"].to_numpy(), example["time_index"].to_numpy()]
1084
+ )
1085
  figure = go.Figure()
1086
  legend_items: list[tuple[str, str]] = []
1087
 
 
1102
  )
1103
  )
1104
  legend_items.append((condition_label(dataset, condition_id), color))
1105
+ add_linked_target_traces(
1106
+ figure,
1107
+ example["target_0"],
1108
+ example["target_1"],
1109
+ example_customdata,
1110
+ (
1111
+ "Horizontal position=%{x:.3f}<br>Vertical position=%{y:.3f}<br>"
1112
+ "Paired bin=%{customdata[1]:.0f}<extra>Example</extra>"
1113
+ ),
1114
+ base_width=3,
1115
+ progress_width=4,
1116
+ halo_width=7,
1117
  )
1118
  figure.update_xaxes(title="Horizontal hand position")
1119
  figure.update_yaxes(title="Vertical hand position", scaleanchor="x", scaleratio=1)
 
1147
  )
1148
  )
1149
  legend_items.append((MC_PROFILE_LABELS[condition_id], color))
1150
+ add_linked_target_traces(
1151
+ figure,
1152
+ example["time_ms"],
1153
+ example["target_0"],
1154
+ example_customdata,
1155
+ "Paired bin=%{customdata[1]:.0f}<br>Force=%{y:.3f}<extra>Example</extra>",
1156
+ base_width=3,
1157
+ progress_width=4,
 
1158
  )
1159
  figure.add_vline(x=0, line_color="#71808D", line_dash="dash")
1160
  figure.update_xaxes(title="Time from scoring onset (ms)")
 
1173
  hovertemplate="x=%{x:.3f}<br>y=%{y:.3f}<br>Samples=%{z}<extra></extra>",
1174
  )
1175
  )
1176
+ add_linked_target_traces(
1177
+ figure,
1178
+ example["target_0"],
1179
+ example["target_1"],
1180
+ example_customdata,
1181
+ (
1182
+ "x=%{x:.3f}<br>y=%{y:.3f}<br>"
1183
+ "Paired bin=%{customdata[1]:.0f}<extra>Example</extra>"
1184
+ ),
1185
+ base_width=2.5,
1186
+ progress_width=3.5,
1187
+ halo_width=5,
 
 
 
 
 
 
 
 
1188
  )
1189
  figure.update_xaxes(title="x position", range=[0, 1])
1190
  figure.update_yaxes(title="y position", range=[0, 1], scaleanchor="x", scaleratio=1)
 
1192
  aria_label = "All simulated position targets shown as spatial occupancy density."
1193
 
1194
  figure_layout(figure, height=410)
1195
+ figure.update_layout(
1196
+ margin=dict(l=62, r=24, t=18, b=62),
1197
+ showlegend=False,
1198
+ meta={"benchdash_dataset": dataset},
1199
+ uirevision=f"target-space-{dataset}",
1200
+ )
1201
  return html.Div(
1202
  [
1203
  html.Div(
 
1211
  )
1212
 
1213
 
1214
+ def dataset_link_payload(dataset: str, example: pd.DataFrame) -> dict:
1215
+ if dataset in {"allen_neuropixels", "speech"}:
1216
+ return {"dataset": dataset, "enabled": False}
1217
+ for column in ["time_index", "time_ms", "target_0", "target_1"]:
1218
+ example[column] = pd.to_numeric(example[column], errors="coerce")
1219
+ example = example.sort_values("time_index")
1220
+ if dataset == "mc_pacman":
1221
+ plot_x = example["time_ms"].to_numpy(dtype=float)
1222
+ plot_y = example["target_0"].to_numpy(dtype=float)
1223
+ value_kind = "force"
1224
+ else:
1225
+ plot_x = example["target_0"].to_numpy(dtype=float)
1226
+ plot_y = example["target_1"].to_numpy(dtype=float)
1227
+ value_kind = "position"
1228
+ times = example["time_ms"].to_numpy(dtype=float)
1229
+ return {
1230
+ "dataset": dataset,
1231
+ "enabled": True,
1232
+ "time_index": example["time_index"].astype(int).tolist(),
1233
+ "time_ms": times.tolist(),
1234
+ "plot_x": plot_x.tolist(),
1235
+ "plot_y": plot_y.tolist(),
1236
+ "value_kind": value_kind,
1237
+ "initial_index": int(np.argmin(np.abs(times))),
1238
+ }
1239
+
1240
+
1241
+ def dataset_example_figures(dataset: str) -> tuple[go.Figure, html.Div, str, dict]:
1242
  metadata = dataset_overview[dataset_overview["dataset"].astype(str).eq(dataset)].iloc[0]
1243
  neural = dataset_example_neural[
1244
  dataset_example_neural["dataset"].astype(str).eq(dataset)
1245
  ].copy()
1246
  targets = dataset_targets[dataset_targets["dataset"].astype(str).eq(dataset)].copy()
1247
  targets["is_example"] = targets["is_example"].astype(str).str.lower().eq("true")
1248
+ example_target = dataset_example_targets[
1249
+ dataset_example_targets["dataset"].astype(str).eq(dataset)
1250
+ ].copy()
1251
  for column in (
1252
  "time_index",
1253
  "time_ms",
 
1272
  .sort_values("feature_display_index")["feature_index"]
1273
  .to_numpy(dtype=int)
1274
  )
1275
+ feature_customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
1276
+ time_customdata = np.repeat(
1277
+ np.arange(values.shape[1], dtype=int)[None, :],
1278
+ values.shape[0],
1279
+ axis=0,
1280
+ )
1281
+ customdata = np.stack([feature_customdata, time_customdata], axis=-1)
1282
  upper = max(int(np.ceil(np.nanmax(values.to_numpy(dtype=float)))), 1)
1283
  if upper <= 4:
1284
  count_ticks = list(range(upper + 1))
 
1303
  ticktext=[str(value) for value in count_ticks],
1304
  ),
1305
  hovertemplate=(
1306
+ "Feature=%{customdata[0]}<br>Time=%{x:.0f} ms<br>"
1307
  "Count=%{z:.0f}<extra></extra>"
1308
  ),
1309
  )
1310
  )
1311
+ neural_figure.add_vline(x=0, line_color="#71808D", line_dash="dash")
1312
  neural_figure.update_xaxes(title="Time from scoring onset (ms)")
1313
  neural_figure.update_yaxes(title="Neural features", showticklabels=False)
1314
  figure_layout(neural_figure, height=470)
1315
+ neural_figure.update_layout(
1316
+ margin=dict(l=58, r=62, t=18, b=62),
1317
+ meta={"benchdash_dataset": dataset},
1318
+ uirevision=f"dataset-neural-{dataset}",
1319
+ )
1320
 
1321
  if dataset in {"allen_neuropixels", "speech"}:
1322
  target_component = target_class_space(dataset, targets)
1323
  else:
1324
+ target_component = target_trajectory_space(dataset, targets, example_target.copy())
1325
 
1326
  shown = int(metadata.example_features_shown)
1327
  total = int(metadata.array_shape.split("×")[-1].strip())
 
1330
  if shown == total
1331
  else f"{shown} of {total} neural features are shown for legibility"
1332
  )
1333
+ if dataset in {"allen_neuropixels", "speech"}:
1334
+ description = (
1335
+ f"The highlighted target corresponds to the neural activity shown at left; "
1336
+ f"{feature_text}."
1337
+ )
1338
+ else:
1339
+ description = f"{feature_text.capitalize()}."
1340
+ return (
1341
+ neural_figure,
1342
+ target_component,
1343
+ description,
1344
+ dataset_link_payload(dataset, example_target),
1345
  )
 
1346
 
1347
 
1348
  def overview_cards(dataset: str, models: Sequence[str] | None) -> list[html.Div]:
 
2880
  ),
2881
  panel(
2882
  "Neural activity and task targets",
2883
+ dcc.Store(id="dataset-link-data"),
2884
+ html.Span(
2885
+ id="dataset-link-render-token",
2886
+ className="feature-story-render-token",
2887
+ ),
2888
  html.Div(
2889
  [
2890
  html.Div(
 
2901
  "Example trial neural activity for the selected dataset.",
2902
  ),
2903
  ],
2904
+ className="dataset-example-card dataset-neural-card",
2905
+ ),
2906
+ html.Div(
2907
+ id="dataset-link-controls",
2908
+ className="dataset-link-controls",
2909
  ),
2910
  html.Div(
2911
  id="dataset-target-space",
2912
+ className="dataset-example-card dataset-target-card",
2913
  ),
2914
  ],
2915
  className="dataset-example-grid",
 
3237
  Output("dataset-neural-example", "figure"),
3238
  Output("dataset-target-space", "children"),
3239
  Output("dataset-example-description", "children"),
3240
+ Output("dataset-link-data", "data"),
3241
  Input("dataset-filter", "value"),
3242
  )
3243
  def update_dataset_examples(dataset: str):
3244
  dataset = dataset or DATASETS[0]
3245
+ neural, target, description, link_data = dataset_example_figures(dataset)
3246
+ return dataset_cards(dataset), neural, target, description, link_data
3247
+
3248
+
3249
+ app.clientside_callback(
3250
+ """
3251
+ function(linkData, activeTab) {
3252
+ if (!window.benchdashDatasetLink) {
3253
+ return window.dash_clientside.no_update;
3254
+ }
3255
+ return window.benchdashDatasetLink.schedule(linkData, activeTab);
3256
+ }
3257
+ """,
3258
+ Output("dataset-link-render-token", "children"),
3259
+ Input("dataset-link-data", "data"),
3260
+ Input("tabs", "value"),
3261
+ )
3262
 
3263
 
3264
  @app.callback(
assets/dataset_link.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ "use strict";
3
+
4
+ const CURSOR_COLOR = "#D55E00";
5
+ const PLAYBACK_DURATION_MS = 4200;
6
+ const autoplaySeen = new Set();
7
+ let activeState = null;
8
+ let scheduleGeneration = 0;
9
+
10
+ function clamp(value, low, high) {
11
+ return Math.max(low, Math.min(high, value));
12
+ }
13
+
14
+ function traceRole(trace) {
15
+ return trace && trace.meta && trace.meta.benchdash_role;
16
+ }
17
+
18
+ function traceIndex(graph, role) {
19
+ return graph.data.findIndex((trace) => traceRole(trace) === role);
20
+ }
21
+
22
+ function graphDataset(graph) {
23
+ return graph && graph.layout && graph.layout.meta && graph.layout.meta.benchdash_dataset;
24
+ }
25
+
26
+ function formatNumber(value, digits) {
27
+ if (!Number.isFinite(Number(value))) return "—";
28
+ const number = Number(value);
29
+ const magnitude = Math.abs(number);
30
+ const precision = magnitude >= 10 ? 2 : digits;
31
+ return number.toFixed(precision).replace(/^-/, "−");
32
+ }
33
+
34
+ function formatTime(value) {
35
+ const rounded = Math.round(Number(value));
36
+ if (rounded === 0) return "Neural time 0 ms";
37
+ return `Neural time ${rounded < 0 ? "−" : "+"}${Math.abs(rounded)} ms`;
38
+ }
39
+
40
+ function makeElement(tag, className, text) {
41
+ const node = document.createElement(tag);
42
+ if (className) node.className = className;
43
+ if (text !== undefined) node.textContent = text;
44
+ return node;
45
+ }
46
+
47
+ function buildControls(state) {
48
+ const lead = makeElement("div", "dataset-link-lead");
49
+ lead.append(
50
+ makeElement("strong", "dataset-link-title", "Linked trial"),
51
+ makeElement(
52
+ "span",
53
+ "dataset-link-hint",
54
+ "Hover the raster, scrub the timeline or press play."
55
+ )
56
+ );
57
+
58
+ const button = makeElement("button", "dataset-link-button", "Play");
59
+ button.type = "button";
60
+ button.setAttribute("aria-label", "Play linked neural activity and target trajectory");
61
+
62
+ const slider = makeElement("input", "dataset-link-slider");
63
+ slider.type = "range";
64
+ slider.min = "0";
65
+ slider.max = String(state.payload.time_ms.length - 1);
66
+ slider.step = "1";
67
+ slider.value = String(state.payload.initial_index || 0);
68
+ slider.setAttribute("aria-label", "Linked trial time bin");
69
+
70
+ const readout = makeElement("div", "dataset-link-readout");
71
+ const time = makeElement("span", "dataset-link-time");
72
+ const value = makeElement("span", "dataset-link-value");
73
+ readout.append(time, value);
74
+
75
+ const transport = makeElement("div", "dataset-link-transport");
76
+ transport.append(button, slider, readout);
77
+ state.controls.replaceChildren(lead, transport);
78
+ state.button = button;
79
+ state.slider = slider;
80
+ state.timeReadout = time;
81
+ state.valueReadout = value;
82
+ }
83
+
84
+ function setButton(state) {
85
+ state.button.textContent = state.playing ? "Pause" : "Play";
86
+ state.button.setAttribute(
87
+ "aria-label",
88
+ state.playing
89
+ ? "Pause linked neural activity and target trajectory"
90
+ : "Play linked neural activity and target trajectory"
91
+ );
92
+ }
93
+
94
+ function positionPlayhead(state) {
95
+ if (!state.neural.isConnected || !state.neural._fullLayout) return;
96
+ const layout = state.neural._fullLayout;
97
+ const axis = layout.xaxis;
98
+ const size = layout._size;
99
+ if (!axis || !size) return;
100
+ const time = Number(state.payload.time_ms[state.index]);
101
+ const left = size.l + axis.l2p(time);
102
+ state.playhead.style.left = `${left}px`;
103
+ state.playhead.style.top = `${size.t}px`;
104
+ state.playhead.style.height = `${size.h}px`;
105
+ }
106
+
107
+ function updateReadout(state) {
108
+ const index = state.index;
109
+ const x = Number(state.payload.plot_x[index]);
110
+ const y = Number(state.payload.plot_y[index]);
111
+ state.timeReadout.textContent = formatTime(state.payload.time_ms[index]);
112
+ state.valueReadout.textContent = state.payload.value_kind === "force"
113
+ ? `Paired force ${formatNumber(y, 3)}`
114
+ : `Paired target x ${formatNumber(x, 3)} · y ${formatNumber(y, 3)}`;
115
+ }
116
+
117
+ function renderIndex(state, requestedIndex) {
118
+ const lastIndex = state.payload.time_ms.length - 1;
119
+ const index = clamp(Math.round(Number(requestedIndex)), 0, lastIndex);
120
+ if (!Number.isFinite(index)) return;
121
+ state.index = index;
122
+ state.slider.value = String(index);
123
+ positionPlayhead(state);
124
+ updateReadout(state);
125
+
126
+ if (state.renderFrame !== null) cancelAnimationFrame(state.renderFrame);
127
+ state.renderFrame = requestAnimationFrame(() => {
128
+ state.renderFrame = null;
129
+ if (!state.target.isConnected || !state.target._fullLayout || !window.Plotly) return;
130
+ const progressX = state.payload.plot_x.slice(0, index + 1);
131
+ const progressY = state.payload.plot_y.slice(0, index + 1);
132
+ window.Plotly.restyle(
133
+ state.target,
134
+ {
135
+ x: [progressX, [state.payload.plot_x[index]]],
136
+ y: [progressY, [state.payload.plot_y[index]]],
137
+ },
138
+ [state.progressTrace, state.cursorTrace]
139
+ );
140
+ });
141
+ }
142
+
143
+ function pause(state) {
144
+ state.playing = false;
145
+ if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
146
+ state.animationFrame = null;
147
+ setButton(state);
148
+ }
149
+
150
+ function play(state) {
151
+ pause(state);
152
+ const finalIndex = state.payload.time_ms.length - 1;
153
+ if (state.index >= finalIndex) renderIndex(state, 0);
154
+ const startIndex = state.index;
155
+ const stepDuration = PLAYBACK_DURATION_MS / Math.max(finalIndex, 1);
156
+ const start = performance.now();
157
+ state.playing = true;
158
+ setButton(state);
159
+
160
+ const advance = (now) => {
161
+ if (!state.playing || activeState !== state) return;
162
+ const elapsedSteps = Math.floor((now - start) / stepDuration);
163
+ const nextIndex = Math.min(finalIndex, startIndex + elapsedSteps);
164
+ if (nextIndex !== state.index) renderIndex(state, nextIndex);
165
+ if (nextIndex >= finalIndex) {
166
+ pause(state);
167
+ return;
168
+ }
169
+ state.animationFrame = requestAnimationFrame(advance);
170
+ };
171
+ state.animationFrame = requestAnimationFrame(advance);
172
+ }
173
+
174
+ function eventIndex(state, event) {
175
+ const point = event && event.points && event.points[0];
176
+ const custom = point && point.customdata;
177
+ if (Array.isArray(custom) && Number.isFinite(Number(custom[1]))) {
178
+ return Number(custom[1]);
179
+ }
180
+ const time = point && Number(point.x);
181
+ if (!Number.isFinite(time)) return null;
182
+ let bestIndex = 0;
183
+ let bestDistance = Infinity;
184
+ state.payload.time_ms.forEach((candidate, index) => {
185
+ const distance = Math.abs(Number(candidate) - time);
186
+ if (distance < bestDistance) {
187
+ bestDistance = distance;
188
+ bestIndex = index;
189
+ }
190
+ });
191
+ return bestIndex;
192
+ }
193
+
194
+ function bindInteractions(state) {
195
+ const abort = state.abortController;
196
+ const selectFromNeural = (event) => {
197
+ const index = eventIndex(state, event);
198
+ if (index === null) return;
199
+ pause(state);
200
+ renderIndex(state, index);
201
+ };
202
+ const selectFromTarget = (event) => {
203
+ const point = event && event.points && event.points[0];
204
+ if (!point || point.curveNumber !== state.exampleTrace) return;
205
+ const index = eventIndex(state, event);
206
+ if (index === null) return;
207
+ pause(state);
208
+ renderIndex(state, index);
209
+ };
210
+ state.neural.on("plotly_hover", selectFromNeural);
211
+ state.neural.on("plotly_click", selectFromNeural);
212
+ state.target.on("plotly_hover", selectFromTarget);
213
+ state.target.on("plotly_click", selectFromTarget);
214
+ state.plotlyListeners = [
215
+ [state.neural, "plotly_hover", selectFromNeural],
216
+ [state.neural, "plotly_click", selectFromNeural],
217
+ [state.target, "plotly_hover", selectFromTarget],
218
+ [state.target, "plotly_click", selectFromTarget],
219
+ ];
220
+
221
+ state.button.addEventListener("click", () => {
222
+ if (state.playing) pause(state);
223
+ else play(state);
224
+ }, { signal: abort.signal });
225
+ state.slider.addEventListener("input", () => {
226
+ pause(state);
227
+ renderIndex(state, Number(state.slider.value));
228
+ }, { signal: abort.signal });
229
+ state.resizeObserver = new ResizeObserver(() => {
230
+ requestAnimationFrame(() => positionPlayhead(state));
231
+ });
232
+ state.resizeObserver.observe(state.neural);
233
+ }
234
+
235
+ function maybeAutoplay(state) {
236
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
237
+ const mobile = window.matchMedia("(max-width: 560px)").matches;
238
+ if (reducedMotion || mobile || autoplaySeen.has(state.payload.dataset)) return;
239
+ state.intersectionObserver = new IntersectionObserver((entries) => {
240
+ const visible = entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 0.25);
241
+ if (!visible || activeState !== state) return;
242
+ autoplaySeen.add(state.payload.dataset);
243
+ state.intersectionObserver.disconnect();
244
+ state.intersectionObserver = null;
245
+ state.autoplayTimer = window.setTimeout(() => {
246
+ if (activeState === state) {
247
+ renderIndex(state, 0);
248
+ play(state);
249
+ }
250
+ }, 450);
251
+ }, { threshold: [0.25] });
252
+ state.intersectionObserver.observe(state.controls);
253
+ }
254
+
255
+ function destroy(state) {
256
+ if (!state) return;
257
+ pause(state);
258
+ if (state.renderFrame !== null) cancelAnimationFrame(state.renderFrame);
259
+ if (state.autoplayTimer !== null) window.clearTimeout(state.autoplayTimer);
260
+ if (state.resizeObserver) state.resizeObserver.disconnect();
261
+ if (state.intersectionObserver) state.intersectionObserver.disconnect();
262
+ if (state.abortController) state.abortController.abort();
263
+ (state.plotlyListeners || []).forEach(([graph, event, listener]) => {
264
+ if (graph && typeof graph.removeListener === "function") {
265
+ graph.removeListener(event, listener);
266
+ }
267
+ });
268
+ if (state.playhead) state.playhead.remove();
269
+ }
270
+
271
+ function mount(payload) {
272
+ const controls = document.getElementById("dataset-link-controls");
273
+ const neural = document.querySelector("#dataset-neural-example .js-plotly-plot");
274
+ const target = document.querySelector("#dataset-target-trajectory .js-plotly-plot");
275
+ if (!controls || !neural || !target || !neural._fullLayout || !target._fullLayout) {
276
+ return false;
277
+ }
278
+ if (graphDataset(neural) !== payload.dataset || graphDataset(target) !== payload.dataset) {
279
+ return false;
280
+ }
281
+ const exampleTrace = traceIndex(target, "example_trajectory");
282
+ const progressTrace = traceIndex(target, "linked_target_progress");
283
+ const cursorTrace = traceIndex(target, "linked_target_cursor");
284
+ if ([exampleTrace, progressTrace, cursorTrace].some((index) => index < 0)) return false;
285
+
286
+ const state = {
287
+ payload,
288
+ controls,
289
+ neural,
290
+ target,
291
+ exampleTrace,
292
+ progressTrace,
293
+ cursorTrace,
294
+ index: payload.initial_index || 0,
295
+ playing: false,
296
+ animationFrame: null,
297
+ renderFrame: null,
298
+ autoplayTimer: null,
299
+ resizeObserver: null,
300
+ intersectionObserver: null,
301
+ abortController: new AbortController(),
302
+ plotlyListeners: [],
303
+ };
304
+ const playhead = makeElement("div", "dataset-linked-playhead");
305
+ playhead.style.backgroundColor = CURSOR_COLOR;
306
+ playhead.setAttribute("aria-hidden", "true");
307
+ neural.style.position = "relative";
308
+ neural.appendChild(playhead);
309
+ state.playhead = playhead;
310
+ buildControls(state);
311
+ bindInteractions(state);
312
+ activeState = state;
313
+ renderIndex(state, state.index);
314
+ maybeAutoplay(state);
315
+ return true;
316
+ }
317
+
318
+ function schedule(payload, activeTab) {
319
+ scheduleGeneration += 1;
320
+ const generation = scheduleGeneration;
321
+ destroy(activeState);
322
+ activeState = null;
323
+ const controls = document.getElementById("dataset-link-controls");
324
+ if (!payload || !payload.enabled || activeTab !== "datasets") {
325
+ if (controls) controls.replaceChildren();
326
+ return `${payload && payload.dataset ? payload.dataset : "none"}:inactive`;
327
+ }
328
+ let attempts = 0;
329
+ const tryMount = () => {
330
+ if (generation !== scheduleGeneration) return;
331
+ if (mount(payload)) return;
332
+ attempts += 1;
333
+ if (attempts < 180) requestAnimationFrame(tryMount);
334
+ };
335
+ requestAnimationFrame(tryMount);
336
+ return `${payload.dataset}:scheduled`;
337
+ }
338
+
339
+ window.benchdashDatasetLink = { schedule };
340
+ })();
assets/styles.css CHANGED
@@ -480,6 +480,124 @@ h2 {
480
  background: #ffffff;
481
  }
482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  .dataset-viz-heading {
484
  display: flex;
485
  min-height: 58px;
@@ -1154,6 +1272,25 @@ h2 {
1154
  grid-template-columns: 1fr;
1155
  }
1156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1157
  }
1158
 
1159
  @media (max-width: 820px) {
@@ -1201,6 +1338,11 @@ h2 {
1201
  grid-template-columns: 1fr;
1202
  }
1203
 
 
 
 
 
 
1204
  .feature-method-control {
1205
  max-width: none;
1206
  }
@@ -1241,6 +1383,29 @@ h2 {
1241
  text-align: left;
1242
  }
1243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1244
  .target-class-grid,
1245
  .target-space-legend {
1246
  grid-template-columns: repeat(2, minmax(0, 1fr));
 
480
  background: #ffffff;
481
  }
482
 
483
+ .dataset-neural-card {
484
+ grid-column: 1;
485
+ grid-row: 1;
486
+ }
487
+
488
+ .dataset-target-card {
489
+ grid-column: 2;
490
+ grid-row: 1;
491
+ }
492
+
493
+ .dataset-link-controls {
494
+ grid-column: 1 / -1;
495
+ grid-row: 2;
496
+ }
497
+
498
+ .dataset-link-controls:empty {
499
+ display: none;
500
+ }
501
+
502
+ .dataset-link-controls:not(:empty) {
503
+ display: grid;
504
+ grid-template-columns: minmax(210px, 0.38fr) minmax(420px, 1fr);
505
+ gap: 18px;
506
+ align-items: center;
507
+ padding: 11px 13px;
508
+ border: 1px solid #dbe4e9;
509
+ border-radius: 8px;
510
+ background: #f7f9fa;
511
+ }
512
+
513
+ .dataset-link-lead {
514
+ display: flex;
515
+ min-width: 0;
516
+ flex-direction: column;
517
+ gap: 1px;
518
+ }
519
+
520
+ .dataset-link-title {
521
+ color: #253b49;
522
+ font-size: 11px;
523
+ font-weight: 800;
524
+ letter-spacing: 0.055em;
525
+ text-transform: uppercase;
526
+ }
527
+
528
+ .dataset-link-hint {
529
+ overflow: hidden;
530
+ color: #647681;
531
+ font-size: 10px;
532
+ text-overflow: ellipsis;
533
+ white-space: nowrap;
534
+ }
535
+
536
+ .dataset-link-transport {
537
+ display: grid;
538
+ grid-template-columns: auto minmax(160px, 1fr) minmax(250px, auto);
539
+ gap: 12px;
540
+ align-items: center;
541
+ min-width: 0;
542
+ }
543
+
544
+ .dataset-link-button {
545
+ min-width: 62px;
546
+ min-height: 32px;
547
+ padding: 5px 11px;
548
+ border: 1px solid #b94805;
549
+ border-radius: 6px;
550
+ background: #ffffff;
551
+ color: #a73f04;
552
+ cursor: pointer;
553
+ font: inherit;
554
+ font-size: 11px;
555
+ font-weight: 780;
556
+ }
557
+
558
+ .dataset-link-button:hover {
559
+ background: #fff5ef;
560
+ }
561
+
562
+ .dataset-link-slider {
563
+ width: 100%;
564
+ min-width: 0;
565
+ accent-color: #d55e00;
566
+ cursor: ew-resize;
567
+ }
568
+
569
+ .dataset-link-readout {
570
+ display: flex;
571
+ min-width: 0;
572
+ justify-content: flex-end;
573
+ gap: 10px;
574
+ color: #536773;
575
+ font-size: 10px;
576
+ font-variant-numeric: tabular-nums;
577
+ white-space: nowrap;
578
+ }
579
+
580
+ .dataset-link-time {
581
+ min-width: 112px;
582
+ color: #2f4654;
583
+ font-weight: 760;
584
+ }
585
+
586
+ .dataset-link-value {
587
+ overflow: hidden;
588
+ max-width: 210px;
589
+ text-overflow: ellipsis;
590
+ }
591
+
592
+ .dataset-linked-playhead {
593
+ position: absolute;
594
+ z-index: 8;
595
+ width: 2px;
596
+ border-radius: 999px;
597
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.86);
598
+ pointer-events: none;
599
+ }
600
+
601
  .dataset-viz-heading {
602
  display: flex;
603
  min-height: 58px;
 
1272
  grid-template-columns: 1fr;
1273
  }
1274
 
1275
+ .dataset-neural-card {
1276
+ grid-column: 1;
1277
+ grid-row: 1;
1278
+ }
1279
+
1280
+ .dataset-link-controls {
1281
+ grid-column: 1;
1282
+ grid-row: 2;
1283
+ }
1284
+
1285
+ .dataset-target-card {
1286
+ grid-column: 1;
1287
+ grid-row: 3;
1288
+ }
1289
+
1290
+ .dataset-example-grid:has(.dataset-link-controls:empty) .dataset-target-card {
1291
+ grid-row: 2;
1292
+ }
1293
+
1294
  }
1295
 
1296
  @media (max-width: 820px) {
 
1338
  grid-template-columns: 1fr;
1339
  }
1340
 
1341
+ .dataset-link-controls:not(:empty) {
1342
+ grid-template-columns: 1fr;
1343
+ gap: 8px;
1344
+ }
1345
+
1346
  .feature-method-control {
1347
  max-width: none;
1348
  }
 
1383
  text-align: left;
1384
  }
1385
 
1386
+ .dataset-link-controls:not(:empty) {
1387
+ grid-template-columns: 1fr;
1388
+ gap: 8px;
1389
+ }
1390
+
1391
+ .dataset-link-hint {
1392
+ white-space: normal;
1393
+ }
1394
+
1395
+ .dataset-link-transport {
1396
+ grid-template-columns: auto minmax(120px, 1fr);
1397
+ gap: 8px;
1398
+ }
1399
+
1400
+ .dataset-link-readout {
1401
+ grid-column: 1 / -1;
1402
+ justify-content: space-between;
1403
+ }
1404
+
1405
+ .dataset-link-value {
1406
+ max-width: 170px;
1407
+ }
1408
+
1409
  .target-class-grid,
1410
  .target-space-legend {
1411
  grid-template-columns: repeat(2, minmax(0, 1fr));