jsongrapher 2.8__py3-none-any.whl → 3.8__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.
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import JSONGrapher.styles.layout_styles_library
3
+ import JSONGrapher.styles.trace_styles_collection_library
2
4
  #TODO: put an option to suppress warnings from JSONRecordCreator
3
5
 
4
6
 
@@ -6,6 +8,7 @@ import json
6
8
  global_records_list = [] #This list holds onto records as they are added. Index 0 is the merged record. Each other index corresponds to record number (like 1 is first record, 2 is second record, etc)
7
9
 
8
10
 
11
+
9
12
  #This is a JSONGrapher specific function
10
13
  #That takes filenames and adds new JSONGrapher records to a global_records_list
11
14
  #If the all_selected_file_paths and newest_file_name_and_path are [] and [], that means to clear the global_records_list.
@@ -48,21 +51,22 @@ def add_records_to_global_records_list_and_plot(all_selected_file_paths, newly_a
48
51
  #This ia JSONGrapher specific wrapper function to drag_and_drop_gui create_and_launch.
49
52
  #This launches the python based JSONGrapher GUI.
50
53
  def launch():
51
- #Check if we have the module we need. First try with package, then locally.
52
54
  try:
53
55
  import JSONGrapher.drag_and_drop_gui as drag_and_drop_gui
54
- except:
55
- #if the package is not present, or does not have it, try getting the module locally.
56
- import drag_and_drop_gui
57
- selected_files = drag_and_drop_gui.create_and_launch(app_name = "JSONGRapher", function_for_after_file_addition=add_records_to_global_records_list_and_plot)
58
- #We will not return the selected_files, and instead will return the global_records_list.
56
+ except ImportError:
57
+ try:
58
+ import drag_and_drop_gui # Attempt local import
59
+ except ImportError as exc:
60
+ raise ImportError("Module 'drag_and_drop_gui' could not be found locally or in JSONGrapher.") from exc
61
+ _selected_files = drag_and_drop_gui.create_and_launch(app_name = "JSONGrapher", function_for_after_file_addition=add_records_to_global_records_list_and_plot)
62
+ #We will not return the _selected_files, and instead will return the global_records_list.
59
63
  return global_records_list
60
64
 
61
65
  ## End of the portion of the code for the GUI##
62
66
 
63
67
 
64
68
  #the function create_new_JSONGrapherRecord is intended to be "like" a wrapper function for people who find it more
65
- # intuitive to create class objects that way, this variable is actually just a reference
69
+ # intuitive to create class objects that way, this variable is actually just a reference
66
70
  # so that we don't have to map the arguments.
67
71
  def create_new_JSONGrapherRecord(hints=False):
68
72
  #we will create a new record. While we could populate it with the init,
@@ -72,23 +76,34 @@ def create_new_JSONGrapherRecord(hints=False):
72
76
  new_record.add_hints()
73
77
  return new_record
74
78
 
79
+ #This is actually a wrapper around merge_JSONGrapherRecords. Made for convenience.
80
+ def load_JSONGrapherRecords(recordsList):
81
+ return merge_JSONGrapherRecords(recordsList)
82
+
83
+ #This is actually a wrapper around merge_JSONGrapherRecords. Made for convenience.
84
+ def import_JSONGrapherRecords(recordsList):
85
+ return merge_JSONGrapherRecords(recordsList)
75
86
 
76
87
  #This is a function for merging JSONGrapher records.
77
88
  #recordsList is a list of records
78
89
  #Each record can be a JSONGrapherRecord object (a python class object) or a dictionary (meaning, a JSONGrapher JSON as a dictionary)
79
90
  #If a record is received that is a string, then the function will attempt to convert that into a dictionary.
80
91
  #The units used will be that of the first record encountered
92
+ #if changing this function's arguments, then also change those for load_JSONGrapherRecords and import_JSONGrapherRecords
81
93
  def merge_JSONGrapherRecords(recordsList):
94
+ if type(recordsList) == type(""):
95
+ recordsList = [recordsList]
82
96
  import copy
83
97
  recordsAsDictionariesList = []
84
98
  merged_JSONGrapherRecord = create_new_JSONGrapherRecord()
85
99
  #first make a list of all the records as dictionaries.
86
100
  for record in recordsList:
87
- if type(record) == type({}):
101
+ if isinstance(record, dict):#can't use type({}) or SyncedDict won't be included.
88
102
  recordsAsDictionariesList.append(record)
89
103
  elif type(record) == type("string"):
90
- record = json.loads(record)
91
- recordsAsDictionariesList.append(record)
104
+ new_record = create_new_JSONGrapherRecord()
105
+ new_fig_dict = new_record.import_from_json(record)
106
+ recordsAsDictionariesList.append(new_fig_dict)
92
107
  else: #this assumpes there is a JSONGrapherRecord type received.
93
108
  record = record.fig_dict
94
109
  recordsAsDictionariesList.append(record)
@@ -128,8 +143,30 @@ def merge_JSONGrapherRecords(recordsList):
128
143
  #now, add the scaled data objects to the original one.
129
144
  #This is fairly easy using a list extend.
130
145
  merged_JSONGrapherRecord.fig_dict["data"].extend(scaled_fig_dict["data"])
146
+ merged_JSONGrapherRecord = convert_JSONGRapherRecord_data_list_to_class_objects(merged_JSONGrapherRecord)
131
147
  return merged_JSONGrapherRecord
132
148
 
149
+ def convert_JSONGRapherRecord_data_list_to_class_objects(record):
150
+ #will also support receiving a fig_dict
151
+ if isinstance(record, dict):
152
+ fig_dict_received = True
153
+ fig_dict = record
154
+ else:
155
+ fig_dict_received = False
156
+ fig_dict = record.fig_dict
157
+ data_list = fig_dict["data"]
158
+ #Do the casting into data_series objects by creating a fresh JSONDataSeries object and populating it.
159
+ for data_series_index, data_series_received in enumerate(data_list):
160
+ JSONGrapher_data_series_object = JSONGrapherDataSeries()
161
+ JSONGrapher_data_series_object.update_while_preserving_old_terms(data_series_received)
162
+ data_list[data_series_index] = JSONGrapher_data_series_object
163
+ #Now prepare for return.
164
+ if fig_dict_received == True:
165
+ fig_dict["data"] = data_list
166
+ record = fig_dict
167
+ if fig_dict_received == False:
168
+ record.fig_dict["data"] = data_list
169
+ return record
133
170
 
134
171
  ### Start of portion of the file that has functions for scaling data to the same units ###
135
172
  #The below function takes two units strings, such as
@@ -168,12 +205,22 @@ def get_units_scaling_ratio(units_string_1, units_string_2):
168
205
  #While it may be possible to find a way using the "Q" objects directly, this is the way I found so far, which converts the U object into a Q object.
169
206
  units_object_converted = 1*unitpy.U(units_string_1)
170
207
  ratio_with_units_object = units_object_converted.to(units_string_2)
171
- except: #the above can fail if there are reciprocal units like 1/bar rather than (bar)**(-1), so we have an except statement that tries "that" fix if there is a failure.
208
+ #the above can fail if there are reciprocal units like 1/bar rather than (bar)**(-1), so we have an except statement that tries "that" fix if there is a failure.
209
+ except Exception as general_exception: # This is so VS code pylint does not flag this line. pylint: disable=broad-except, disable=unused-variable
172
210
  units_string_1 = convert_inverse_units(units_string_1)
173
211
  units_string_2 = convert_inverse_units(units_string_2)
174
212
  units_object_converted = 1*unitpy.U(units_string_1)
175
- ratio_with_units_object = units_object_converted.to(units_string_2)
213
+ try:
214
+ ratio_with_units_object = units_object_converted.to(units_string_2)
215
+ except KeyError as e:
216
+ raise KeyError(f"Error during unit conversion in get_units_scaling_ratio: Missing key {e}. Ensure all unit definitions are correctly set. Unit 1: {units_string_1}, Unit 2: {units_string_2}") from e
217
+ except ValueError as e:
218
+ raise ValueError(f"Error during unit conversion in get_units_scaling_ratio: {e}. Make sure unit values are valid and properly formatted. Unit 1: {units_string_1}, Unit 2: {units_string_2}") from e
219
+ except Exception as e: # pylint: disable=broad-except
220
+ raise RuntimeError(f"An unexpected error occurred in get_units_scaling_ratio when trying to convert units: {e}. Double-check that your records have the same units. Unit 1: {units_string_1}, Unit 2: {units_string_2}") from e
221
+
176
222
  ratio_with_units_string = str(ratio_with_units_object)
223
+
177
224
  ratio_only = ratio_with_units_string.split(' ')[0] #what comes out may look like 1000 gram/(meter second), so we split and take first part.
178
225
  ratio_only = float(ratio_only)
179
226
  return ratio_only #function returns ratio only. If function is later changed to return more, then units_strings may need further replacements.
@@ -183,7 +230,7 @@ def return_custom_units_markup(units_string, custom_units_list):
183
230
  sorted_custom_units_list = sorted(custom_units_list, key=len, reverse=True)
184
231
  #the units should be sorted from longest to shortest if not already sorted that way.
185
232
  for custom_unit in sorted_custom_units_list:
186
- units_string.replace(custom_unit, '<'+custom_unit+'>')
233
+ units_string = units_string.replace(custom_unit, '<'+custom_unit+'>')
187
234
  return units_string
188
235
 
189
236
  #This function tags microunits.
@@ -235,7 +282,7 @@ def add_custom_unit_to_unitpy(unit_string):
235
282
  # and for the new entry, it will also return a special NoneType that we can't easy check.
236
283
  # the structer unitpy.ledger.units is a list, but unitpy.ledger._lookup is a dictionary we can use
237
284
  # to check if the key for the new unit is added or not.
238
- if unit_string not in unitpy.ledger._lookup:
285
+ if unit_string not in unitpy.ledger._lookup: #This comment is so the VS code pylint does not flag this line. pylint: disable=protected-access
239
286
  unitpy.ledger.add_unit(new_entry) #implied return is here. No return needed.
240
287
 
241
288
  def extract_tagged_strings(text):
@@ -273,11 +320,11 @@ def scale_fig_dict_values(fig_dict, num_to_scale_x_values_by = 1, num_to_scale_y
273
320
  #iterate across the data objects inside, and change them.
274
321
  for data_index, dataseries in enumerate(scaled_fig_dict["data"]):
275
322
  dataseries = scale_dataseries_dict(dataseries, num_to_scale_x_values_by=num_to_scale_x_values_by, num_to_scale_y_values_by=num_to_scale_y_values_by)
276
- scaled_fig_dict[data_index] = dataseries #this line shouldn't be needed due to mutable references, but adding for clarity and to be safe.
323
+ scaled_fig_dict["data"][data_index] = dataseries #this line shouldn't be needed due to mutable references, but adding for clarity and to be safe.
277
324
  return scaled_fig_dict
278
325
 
279
326
 
280
- def scale_dataseries_dict(dataseries_dict, num_to_scale_x_values_by = 1, num_to_scale_y_values_by = 1):
327
+ def scale_dataseries_dict(dataseries_dict, num_to_scale_x_values_by = 1, num_to_scale_y_values_by = 1, num_to_scale_z_values_by = 1):
281
328
  import numpy as np
282
329
  dataseries = dataseries_dict
283
330
  dataseries["x"] = list(np.array(dataseries["x"], dtype=float)*num_to_scale_x_values_by) #convert to numpy array for multiplication, then back to list.
@@ -286,15 +333,233 @@ def scale_dataseries_dict(dataseries_dict, num_to_scale_x_values_by = 1, num_to_
286
333
  # Ensure elements are converted to standard Python types.
287
334
  dataseries["x"] = [float(val) for val in dataseries["x"]] #This line written by copilot.
288
335
  dataseries["y"] = [float(val) for val in dataseries["y"]] #This line written by copilot.
336
+
337
+ if "z" in dataseries:
338
+ dataseries["z"] = list(np.array(dataseries["z"], dtype=float)*num_to_scale_z_values_by) #convert to numpy array for multiplication, then back to list.
339
+ dataseries["z"] = [float(val) for val in dataseries["z"]] #Mimicking above lines.
289
340
  return dataseries_dict
290
341
 
291
342
  ### End of portion of the file that has functions for scaling data to the same units ###
292
343
 
344
+ ## This is a special dictionary class that will allow a dictionary
345
+ ## inside a main class object to be synchronized with the fields within it.
346
+ class SyncedDict(dict):
347
+ """A dictionary that automatically updates instance attributes."""
348
+ def __init__(self, owner):
349
+ super().__init__()
350
+ self.owner = owner # Store reference to the class instance
351
+ def __setitem__(self, key, value):
352
+ """Update both dictionary and instance attribute."""
353
+ super().__setitem__(key, value) # Set in the dictionary
354
+ setattr(self.owner, key, value) # Sync with instance attribute
355
+ def __delitem__(self, key):
356
+ super().__delitem__(key) # Remove from dict
357
+ if hasattr(self.owner, key):
358
+ delattr(self.owner, key) # Sync removal from instance
359
+ def pop(self, key, *args):
360
+ """Remove item from dictionary and instance attributes."""
361
+ value = super().pop(key, *args) # Remove from dictionary
362
+ if hasattr(self.owner, key):
363
+ delattr(self.owner, key) # Remove from instance attributes
364
+ return value
365
+ def update(self, *args, **kwargs):
366
+ super().update(*args, **kwargs) # Update dict
367
+ for key, value in self.items():
368
+ setattr(self.owner, key, value) # Sync attributes
369
+
370
+
371
+ class JSONGrapherDataSeries(dict): #inherits from dict.
372
+ def __init__(self, uid="", name="", trace_style="", x=None, y=None, **kwargs):
373
+ """Initialize a data series with synced dictionary behavior.
374
+ Here are some fields that can be included, with example values.
375
+
376
+ "uid": data_series_dict["uid"] = "123ABC", # (string) a unique identifier
377
+ "name": data_series_dict["name"] = "Sample Data Series", # (string) name of the data series
378
+ "trace_style": data_series_dict["trace_style"] = "scatter", # (string) type of trace (e.g., scatter, bar)
379
+ "x": data_series_dict["x"] = [1, 2, 3, 4, 5], # (list) x-axis values
380
+ "y": data_series_dict["y"] = [10, 20, 30, 40, 50], # (list) y-axis values
381
+ "mode": data_series_dict["mode"] = "lines", # (string) plot mode (e.g., "lines", "markers")
382
+ "marker_size": data_series_dict["marker"]["size"] = 6, # (integer) marker size
383
+ "marker_color": data_series_dict["marker"]["color"] = "blue", # (string) marker color
384
+ "marker_symbol": data_series_dict["marker"]["symbol"] = "circle", # (string) marker shape/symbol
385
+ "line_width": data_series_dict["line"]["width"] = 2, # (integer) line thickness
386
+ "line_dash": data_series_dict["line"]["dash"] = "solid", # (string) line style (solid, dash, etc.)
387
+ "opacity": data_series_dict["opacity"] = 0.8, # (float) transparency level (0-1)
388
+ "visible": data_series_dict["visible"] = True, # (boolean) whether the trace is visible
389
+ "hoverinfo": data_series_dict["hoverinfo"] = "x+y", # (string) format for hover display
390
+ "legend_group": data_series_dict["legend_group"] = None, # (string or None) optional grouping for legend
391
+ "text": data_series_dict["text"] = "Data Point Labels", # (string or None) optional text annotations
392
+
393
+ """
394
+ super().__init__() # Initialize as a dictionary
395
+
396
+ # Default trace properties
397
+ self.update({
398
+ "uid": uid,
399
+ "name": name,
400
+ "trace_style": trace_style,
401
+ "x": list(x) if x else [],
402
+ "y": list(y) if y else []
403
+ })
404
+
405
+ # Include any extra keyword arguments passed in
406
+ self.update(kwargs)
407
+
408
+ def update_while_preserving_old_terms(self, series_dict):
409
+ """Update instance attributes from a dictionary. Overwrites existing terms and preserves other old terms."""
410
+ self.update(series_dict)
411
+
412
+ def get_data_series_dict(self):
413
+ """Return the dictionary representation of the trace."""
414
+ return dict(self)
415
+
416
+ def set_x_values(self, x_values):
417
+ """Update the x-axis values."""
418
+ self["x"] = list(x_values) if x_values else []
419
+
420
+ def set_y_values(self, y_values):
421
+ """Update the y-axis values."""
422
+ self["y"] = list(y_values) if y_values else []
423
+
424
+ def set_name(self, name):
425
+ """Update the name of the data series."""
426
+ self["name"] = name
427
+
428
+ def set_uid(self, uid):
429
+ """Update the unique identifier (uid) of the data series."""
430
+ self["uid"] = uid
431
+
432
+ def set_trace_style(self, style):
433
+ """Update the trace style (e.g., scatter, scatter_spline, scatter_line, bar)."""
434
+ self["trace_style"] = style
435
+
436
+ def set_marker_symbol(self, symbol):
437
+ self.set_marker_shape(shape=symbol)
438
+
439
+ def set_marker_shape(self, shape):
440
+ """
441
+ Update the marker shape (symbol).
442
+
443
+ Supported marker shapes in Plotly:
444
+ - 'circle' (default)
445
+ - 'square'
446
+ - 'diamond'
447
+ - 'cross'
448
+ - 'x'
449
+ - 'triangle-up'
450
+ - 'triangle-down'
451
+ - 'triangle-left'
452
+ - 'triangle-right'
453
+ - 'pentagon'
454
+ - 'hexagon'
455
+ - 'star'
456
+ - 'hexagram'
457
+ - 'star-triangle-up'
458
+ - 'star-triangle-down'
459
+ - 'star-square'
460
+ - 'star-diamond'
461
+ - 'hourglass'
462
+ - 'bowtie'
463
+
464
+ :param shape: String representing the desired marker shape.
465
+ """
466
+ self.setdefault("marker", {})["symbol"] = shape
467
+
468
+ def add_data_point(self, x_val, y_val):
469
+ """Append a new data point to the series."""
470
+ self["x"].append(x_val)
471
+ self["y"].append(y_val)
472
+
473
+ def set_marker_size(self, size):
474
+ """Update the marker size."""
475
+ self.setdefault("marker", {})["size"] = size
476
+
477
+ def set_marker_color(self, color):
478
+ """Update the marker color."""
479
+ self.setdefault("marker", {})["color"] = color
480
+
481
+ def set_mode(self, mode):
482
+ """Update the mode (options: 'lines', 'markers', 'text', 'lines+markers', 'lines+text', 'markers+text', 'lines+markers+text')."""
483
+ # Check if 'line' is in the mode but 'lines' is not. Then correct for user if needed.
484
+ if "line" in mode and "lines" not in mode:
485
+ mode = mode.replace("line", "lines")
486
+ self["mode"] = mode
487
+
488
+ def set_annotations(self, text): #just a convenient wrapper.
489
+ self.set_text(text)
490
+
491
+ def set_text(self, text):
492
+ #text should be a list of strings teh same length as the data series, one string per point.
493
+ """Update the annotations with a list of text as long as the number of data points."""
494
+ if text == type("string"):
495
+ text = [text] * len(self["x"]) # Repeat the text to match x-values length
496
+ else:
497
+ pass #use text directly
498
+ self["text"] = text
499
+
500
+
501
+ def set_line_width(self, width):
502
+ """Update the line width, should be a number, normally an integer."""
503
+ line = self.setdefault("line", {})
504
+ line.setdefault("width", width) # Ensure width is set
505
+
506
+ def set_line_dash(self, dash_style):
507
+ """
508
+ Update the line dash style.
509
+
510
+ Supported dash styles in Plotly:
511
+ - 'solid' (default) → Continuous solid line
512
+ - 'dot' → Dotted line
513
+ - 'dash' → Dashed line
514
+ - 'longdash' → Longer dashed line
515
+ - 'dashdot' → Dash-dot alternating pattern
516
+ - 'longdashdot' → Long dash-dot alternating pattern
517
+
518
+ :param dash_style: String representing the desired line style.
519
+ """
520
+ self.setdefault("line", {})["dash"] = dash_style
521
+
522
+ def set_transparency(self, transparency_value):
523
+ """
524
+ Update the transparency level by converting it to opacity.
525
+
526
+ Transparency ranges from:
527
+ - 0 (completely opaque) → opacity = 1
528
+ - 1 (fully transparent) → opacity = 0
529
+ - Intermediate values adjust partial transparency.
530
+
531
+ :param transparency_value: Float between 0 and 1, where 0 is opaque and 1 is transparent.
532
+ """
533
+ self["opacity"] = 1 - transparency_value
534
+
535
+ def set_opacity(self, opacity_value):
536
+ """Update the opacity level between 0 and 1."""
537
+ self["opacity"] = opacity_value
538
+
539
+ def set_visible(self, is_visible):
540
+ """Update the visibility of the trace.
541
+ "True" → The trace is fully visible.
542
+ "False" → The trace is completely hidden.
543
+ "legendonly" → The trace is hidden from the plot but still appears in the legend.
544
+
545
+ """
546
+
547
+ self["visible"] = is_visible
548
+
549
+ def set_hoverinfo(self, hover_format):
550
+ """Update hover information format."""
551
+ self["hoverinfo"] = hover_format
552
+
553
+
554
+
293
555
  class JSONGrapherRecord:
294
556
  """
295
557
  This class enables making JSONGrapher records. Each instance represents a structured JSON record for a graph.
296
558
  One can optionally provide an existing JSONGrapher record during creation to pre-populate the object.
297
- One can also manipulate the fig_dict inside, directly, using syntax like Record.fig_dict["comments"] = ...
559
+ One can manipulate the fig_dict inside, directly, using syntax like Record.fig_dict["comments"] = ...
560
+ One can also use syntax like Record["comments"] = ... as some 'magic' synchronizes fields directlyin the Record with fields in the fig_dict.
561
+ However, developers should usually use the syntax like Record.fig_dict, internally, to avoid any referencing mistakes.
562
+
298
563
 
299
564
  Arguments & Attributes (all are optional):
300
565
  comments (str): Can be used to put in general description or metadata related to the entire record. Can include citation links. Goes into the record's top level comments field.
@@ -311,32 +576,36 @@ class JSONGrapherRecord:
311
576
 
312
577
  Methods:
313
578
  add_data_series: Adds a new data series to the record.
314
- set_layout: Updates the layout configuration for the graph.
579
+ add_data_series_as_equation: Adds a new equation to plot, which will be evaluated on the fly.
580
+ set_layout_fields: Updates the layout configuration for the graph.
315
581
  export_to_json_file: Saves the entire record (comments, datatype, data, layout) as a JSON file.
316
582
  populate_from_existing_record: Populates the attributes from an existing JSONGrapher record.
317
583
  """
318
-
319
- def __init__(self, comments="", graph_title="", datatype="", data_objects_list = None, simulate_as_added = True, x_data=None, y_data=None, x_axis_label_including_units="", y_axis_label_including_units ="", plot_type ="", layout={}, existing_JSONGrapher_record=None):
584
+
585
+ def __init__(self, comments="", graph_title="", datatype="", data_objects_list = None, simulate_as_added = True, evaluate_equations_as_added = True, x_data=None, y_data=None, x_axis_label_including_units="", y_axis_label_including_units ="", plot_style ="", layout=None, existing_JSONGrapher_record=None):
320
586
  """
321
587
  Initialize a JSONGrapherRecord instance with optional attributes or an existing record.
322
588
 
323
589
  layout (dict): Layout dictionary to pre-populate the graph configuration.
324
590
  existing_JSONGrapher_record (dict): Existing JSONGrapher record to populate the instance.
325
- """
326
- # Default attributes for a new record.
327
- # Initialize the main record dictionary
328
- # the if statements check if something is empty and populates them if not. This is a special syntax in python that does not require a None object to work, empty also works.
329
-
330
- #if receiving a data_objects_list, validate it.
591
+ """
592
+ if layout == None: #it's bad to have an empty dictionary or list as a python argument.
593
+ layout = {}
594
+
595
+ # Assign self.fig_dict in a way that it will push any changes to it into the class instance.
596
+ self.fig_dict = {}
597
+
598
+ # If receiving a data_objects_list, validate it.
331
599
  if data_objects_list:
332
- validate_plotly_data_list(data_objects_list) #call a function from outside the class.
333
- #if receiving axis labels, validate them.
600
+ validate_plotly_data_list(data_objects_list) # Call a function from outside the class.
601
+
602
+ # If receiving axis labels, validate them.
334
603
  if x_axis_label_including_units:
335
604
  validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=False)
336
605
  if y_axis_label_including_units:
337
606
  validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=False)
338
607
 
339
- self.fig_dict = {
608
+ self.fig_dict.update( {
340
609
  "comments": comments, # Top-level comments
341
610
  "datatype": datatype, # Top-level datatype (datatype)
342
611
  "data": data_objects_list if data_objects_list else [], # Data series list
@@ -344,33 +613,76 @@ class JSONGrapherRecord:
344
613
  "title": {"text": graph_title},
345
614
  "xaxis": {"title": {"text": x_axis_label_including_units}},
346
615
  "yaxis": {"title": {"text": y_axis_label_including_units}}
347
- }
348
- }
616
+ }
617
+ }
618
+ )
349
619
 
350
-
351
- if simulate_as_added: #will try to simulate. But because this is the default, will use a try and except rather than crash program.
620
+ if plot_style !="":
621
+ self.fig_dict["plot_style"] = plot_style
622
+ if simulate_as_added: # Will try to simulate, but because this is the default, will use a try-except rather than crash the program.
352
623
  try:
353
624
  self.fig_dict = simulate_as_needed_in_fig_dict(self.fig_dict)
354
- except:
355
- pass
356
-
357
- self.plot_type = plot_type #the plot_type is normally actually a series level attribute. However, if somebody sets the plot_type at the record level, then we will use that plot_type for all of the individual series.
358
- if plot_type != "":
359
- self.fig_dict["plot_type"] = plot_type
625
+ except KeyError:
626
+ pass # Handle missing key issues gracefully
627
+ except Exception as e: # This is so VS code pylint does not flag this line: pylint: disable=broad-except
628
+ print(f"Unexpected error: {e}") # Logs any unhandled errors
360
629
 
361
- # Populate attributes if an existing JSONGrapher record is provided, as a dictionary.
630
+ if evaluate_equations_as_added: # Will try to evaluate, but because this is the default, will use a try-except rather than crash the program.
631
+ try:
632
+ self.fig_dict = evaluate_equations_as_needed_in_fig_dict(self.fig_dict)
633
+ except Exception as e: # This is so VS code pylint does not flag this line. pylint: disable=broad-except, disable=unused-variable
634
+ pass
635
+ # Populate attributes if an existing JSONGrapher record is provided as a dictionary.
362
636
  if existing_JSONGrapher_record:
363
637
  self.populate_from_existing_record(existing_JSONGrapher_record)
364
638
 
365
639
  # Initialize the hints dictionary, for use later, since the actual locations in the JSONRecord can be non-intuitive.
366
640
  self.hints_dictionary = {}
367
641
  # Adding hints. Here, the keys are the full field locations within the record.
368
- self.hints_dictionary["['comments']"] = "Use Record.set_comments() to populate this field. Can be used to put in a general description or metadata related to the entire record. Can include citations and links. Goes into the record's top level comments field."
369
- self.hints_dictionary["['datatype']"] = "Use Record.set_datatype() to populate this field. This is the datatype, like experiment type, and is used to assess which records can be compared and which (if any) schema to compare to. Use of single underscores between words is recommended. Avoid using double underscores '__' in this field unless you have read the manual about hierarchical datatypes. The user can choose to provide a URL to a schema in this field, rather than a dataype name."
642
+ self.hints_dictionary["['comments']"] = "Use Record.set_comments() to populate this field. Can be used to put in a general description or metadata related to the entire record. Can include citations and links. Goes into the record's top-level comments field."
643
+ self.hints_dictionary["['datatype']"] = "Use Record.set_datatype() to populate this field. This is the datatype, like experiment type, and is used to assess which records can be compared and which (if any) schema to compare to. Use of single underscores between words is recommended. Avoid using double underscores '__' in this field unless you have read the manual about hierarchical datatypes. The user can choose to provide a URL to a schema in this field rather than a datatype name."
370
644
  self.hints_dictionary["['layout']['title']['text']"] = "Use Record.set_graph_title() to populate this field. This is the title for the graph."
371
- self.hints_dictionary["['layout']['xaxis']['title']['text']"] = "Use Record.set_x_axis_label() to populate this field. This is the x axis label and should have units in parentheses. The units can include multiplication '*', division '/' and parentheses '( )'. Scientific and imperial units are recommended. Custom units can be contained in pointy brackets'< >'." # x-axis label
372
- self.hints_dictionary["['layout']['yaxis']['title']['text']"] = "Use Record.set_y_axis_label() to populate this field. This is the y axis label and should have units in parentheses. The units can include multiplication '*', division '/' and parentheses '( )'. Scientific and imperial units are recommended. Custom units can be contained in pointy brackets'< >'."
645
+ self.hints_dictionary["['layout']['xaxis']['title']['text']"] = "Use Record.set_x_axis_label() to populate this field. This is the x-axis label and should have units in parentheses. The units can include multiplication '*', division '/' and parentheses '( )'. Scientific and imperial units are recommended. Custom units can be contained in pointy brackets '< >'." # x-axis label
646
+ self.hints_dictionary["['layout']['yaxis']['title']['text']"] = "Use Record.set_y_axis_label() to populate this field. This is the y-axis label and should have units in parentheses. The units can include multiplication '*', division '/' and parentheses '( )'. Scientific and imperial units are recommended. Custom units can be contained in pointy brackets '< >'."
647
+
648
+ ##Start of section of class code that allows class to behave like a dictionary and synchronize with fig_dict ##
649
+ #The __getitem__ and __setitem__ functions allow the class instance to behave 'like' a dictionary without using super.
650
+ #The below functions allow the JSONGrapherRecord to populate the self.fig_dict each time something is added inside.
651
+ #That is, if someone uses something like Record["comments"]="frog", it will also put that into self.fig_dict
652
+
653
+ def __getitem__(self, key):
654
+ return self.fig_dict[key] # Direct access
655
+
656
+ def __setitem__(self, key, value):
657
+ self.fig_dict[key] = value # Direct modification
373
658
 
659
+ def __delitem__(self, key):
660
+ del self.fig_dict[key] # Support for deletion
661
+
662
+ def __iter__(self):
663
+ return iter(self.fig_dict) # Allow iteration
664
+
665
+ def __len__(self):
666
+ return len(self.fig_dict) # Support len()
667
+
668
+ def pop(self, key, default=None):
669
+ return self.fig_dict.pop(key, default) # Implement pop()
670
+
671
+ def keys(self):
672
+ return self.fig_dict.keys() # Dictionary-like keys()
673
+
674
+ def values(self):
675
+ return self.fig_dict.values() # Dictionary-like values()
676
+
677
+ def items(self):
678
+ return self.fig_dict.items() # Dictionary-like items()
679
+
680
+ def update(self, *args, **kwargs):
681
+ """Updates the dictionary with multiple key-value pairs."""
682
+ self.fig_dict.update(*args, **kwargs)
683
+
684
+
685
+ ##End of section of class code that allows class to behave like a dictionary and synchronize with fig_dict ##
374
686
 
375
687
  #this function enables printing the current record.
376
688
  def __str__(self):
@@ -381,7 +693,7 @@ class JSONGrapherRecord:
381
693
  return json.dumps(self.fig_dict, indent=4)
382
694
 
383
695
 
384
- def add_data_series(self, series_name, x_values=[], y_values=[], simulate={}, simulate_as_added = True, comments="", plot_type="", uid="", line="", extra_fields=None):
696
+ def add_data_series(self, series_name, x_values=None, y_values=None, simulate=None, simulate_as_added=True, comments="", trace_style="", uid="", line="", extra_fields=None):
385
697
  """
386
698
  This is the normal way of adding an x,y data series.
387
699
  """
@@ -391,10 +703,18 @@ class JSONGrapherRecord:
391
703
  # simulate: This is an optional field which, if used, is a JSON object with entries for calling external simulation scripts.
392
704
  # simulate_as_added: Boolean for calling simulation scripts immediately.
393
705
  # comments: Optional description of the data series.
394
- # plot_type: Type of the data (e.g., scatter, line).
706
+ # trace_style: Type of the data (e.g., scatter, line, scatter_spline, spline, bar).
395
707
  # line: Dictionary describing line properties (e.g., shape, width).
396
708
  # uid: Optional unique identifier for the series (e.g., a DOI).
397
709
  # extra_fields: Dictionary containing additional fields to add to the series.
710
+ #Should not have mutable objects initialized as defaults, so putting them in below.
711
+ if x_values is None:
712
+ x_values = []
713
+ if y_values is None:
714
+ y_values = []
715
+ if simulate is None:
716
+ simulate = {}
717
+
398
718
  x_values = list(x_values)
399
719
  y_values = list(y_values)
400
720
 
@@ -406,48 +726,122 @@ class JSONGrapherRecord:
406
726
 
407
727
  #Add optional inputs.
408
728
  if len(comments) > 0:
409
- data_series_dict["comments"]: comments
729
+ data_series_dict["comments"] = comments
410
730
  if len(uid) > 0:
411
- data_series_dict["uid"]: uid
731
+ data_series_dict["uid"] = uid
412
732
  if len(line) > 0:
413
- data_series_dict["line"]: line
733
+ data_series_dict["line"] = line
734
+ if len(trace_style) > 0:
735
+ data_series_dict['trace_style'] = trace_style
414
736
  #add simulate field if included.
415
737
  if simulate:
416
738
  data_series_dict["simulate"] = simulate
417
739
  if simulate_as_added: #will try to simulate. But because this is the default, will use a try and except rather than crash program.
418
740
  try:
419
741
  data_series_dict = simulate_data_series(data_series_dict)
420
- except:
742
+ except Exception as e: # This is so VS code pylint does not flag this line. pylint: disable=broad-except, disable=unused-variable
421
743
  pass
422
744
  # Add extra fields if provided, they will be added.
423
745
  if extra_fields:
424
746
  data_series_dict.update(extra_fields)
425
- #Add to the class object's data list.
426
- self.fig_dict["data"].append(data_series_dict)
427
- #update plot_type, since our internal function requires the data series to be added already.
428
- if len(plot_type) > 0:
429
- newest_record_index = len(self.fig_dict["data"]) - 1
430
- self.set_plot_type_one_data_series(newest_record_index, plot_type)
431
747
 
748
+ #make this a JSONGrapherDataSeries class object, that way a person can use functions to do things like change marker size etc. more easily.
749
+ JSONGrapher_data_series_object = JSONGrapherDataSeries()
750
+ JSONGrapher_data_series_object.update_while_preserving_old_terms(data_series_dict)
751
+ data_series_dict = JSONGrapher_data_series_object
752
+ #Add to the JSONGrapherRecord class object's data list.
753
+ self.fig_dict["data"].append(data_series_dict) #implied return.
754
+ return data_series_dict
755
+
756
+ def add_data_series_as_equation(self, series_name, x_values=None, y_values=None, equation_dict=None, evaluate_equations_as_added=True, comments="", trace_style="", uid="", line="", extra_fields=None):
757
+ """
758
+ This is a way to add an equation that would be used to fill an x,y data series.
759
+ The equation will be a equation_dict of the json_equationer type
760
+ """
761
+ # series_name: Name of the data series.
762
+ # x: List of x-axis values. Or similar structure.
763
+ # y: List of y-axis values. Or similar structure.
764
+ # equation_dict: This is the field for the equation_dict of json_equationer type
765
+ # evaluate_equations_as_added: Boolean for evaluating equations immediately.
766
+ # comments: Optional description of the data series.
767
+ # plot_type: Type of the data (e.g., scatter, line).
768
+ # line: Dictionary describing line properties (e.g., shape, width).
769
+ # uid: Optional unique identifier for the series (e.g., a DOI).
770
+ # extra_fields: Dictionary containing additional fields to add to the series.
771
+ #Should not have mutable objects initialized as defaults, so putting them in below.
772
+ if x_values is None:
773
+ x_values = []
774
+ if y_values is None:
775
+ y_values = []
776
+ if equation_dict is None:
777
+ equation_dict = {}
778
+
779
+ x_values = list(x_values)
780
+ y_values = list(y_values)
781
+
782
+ data_series_dict = {
783
+ "name": series_name,
784
+ "x": x_values,
785
+ "y": y_values,
786
+ }
787
+
788
+ #Add optional inputs.
789
+ if len(comments) > 0:
790
+ data_series_dict["comments"] = comments
791
+ if len(uid) > 0:
792
+ data_series_dict["uid"] = uid
793
+ if len(line) > 0:
794
+ data_series_dict["line"] = line
795
+ if len(trace_style) > 0:
796
+ data_series_dict['trace_style'] = trace_style
797
+ #add equation field if included.
798
+ if equation_dict:
799
+ data_series_dict["equation"] = equation_dict
800
+ # Add extra fields if provided, they will be added.
801
+ if extra_fields:
802
+ data_series_dict.update(extra_fields)
803
+
804
+ #make this a JSONGrapherDataSeries class object, that way a person can use functions to do things like change marker size etc. more easily.
805
+ JSONGrapher_data_series_object = JSONGrapherDataSeries()
806
+ JSONGrapher_data_series_object.update_while_preserving_old_terms(data_series_dict)
807
+ data_series_dict = JSONGrapher_data_series_object
808
+ #Add to the JSONGrapherRecord class object's data list.
809
+ self.fig_dict["data"].append(data_series_dict)
810
+ #Now evaluate the equation as added, if requested. It does seem counterintuitive to do this "at the end",
811
+ #but the reason this happens at the end is that the evaluation *must* occur after being a fig_dict because we
812
+ #need to check the units coming out against the units in the layout. Otherwise we would not be able to convert units.
813
+ new_data_series_index = len(self.fig_dict["data"])-1
814
+ if evaluate_equations_as_added: #will try to simulate. But because this is the default, will use a try and except rather than crash program.
815
+ try:
816
+ self.fig_dict = evaluate_equation_for_data_series_by_index(self.fig_dict, new_data_series_index)
817
+ except Exception as e: # This is so VS code pylint does not flag this line. pylint: disable=broad-except, disable=unused-variable
818
+ pass
819
+
432
820
  def change_data_series_name(self, series_index, series_name):
433
821
  self.fig_dict["data"][series_index]["name"] = series_name
434
822
 
435
823
  #this function forces the re-simulation of a particular dataseries.
436
824
  #The simulator link will be extracted from the record, by default.
437
825
  def simulate_data_series_by_index(self, data_series_index, simulator_link='', verbose=False):
438
- data_series_dict = self.fig_dict["data"][data_series_index]
439
- data_series_dict = simulate_data_series(data_series_dict, simulator_link=simulator_link, verbose=verbose)
440
- self.fig_dict["data"][data_series_index] = data_series_dict #implied return
826
+ self.fig_dict = simulate_specific_data_series_by_index(fig_dict=self.fig_dict, data_series_index=data_series_index, simulator_link=simulator_link, verbose=verbose)
827
+ data_series_dict = self.fig_dict["data"][data_series_index] #implied return
441
828
  return data_series_dict #Extra regular return
442
-
443
829
  #this function returns the current record.
830
+
831
+ def evaluate_eqution_of_data_series_by_index(self, series_index, equation_dict = None, verbose=False):
832
+ if equation_dict != None:
833
+ self.fig_dict["data"][series_index]["equation"] = equation_dict
834
+ self.fig_dict = evaluate_equation_for_data_series_by_index(data_series_index=data_series_dict, verbose=verbose) #implied return.
835
+ return data_series_dict #Extra regular return
836
+
837
+ #this function returns the current record.
444
838
  def get_record(self):
445
839
  """
446
840
  Returns a JSON-dict string of the record
447
841
  """
448
842
  return self.fig_dict
449
-
450
843
  #The update_and_validate function will clean for plotly.
844
+ #TODO: the internal recommending "print_to_inspect" function should, by default, exclude printing the full dictionaries of the layout_style and the trace_collection_style.
451
845
  def print_to_inspect(self, update_and_validate=True, validate=True, remove_remaining_hints=False):
452
846
  if remove_remaining_hints == True:
453
847
  self.remove_hints()
@@ -463,13 +857,14 @@ class JSONGrapherRecord:
463
857
  existing_JSONGrapher_record: A dictionary representing an existing JSONGrapher record.
464
858
  """
465
859
  #While we expect a dictionary, if a JSONGrapher ojbect is provided, we will simply pull the dictionary out of that.
466
- if type(existing_JSONGrapher_record) != type({}):
467
- existing_JSONGrapher_record = existing_JSONGrapher_record.fig_dict
468
- if type(existing_JSONGrapher_record) == type({}):
860
+ if isinstance(existing_JSONGrapher_record, dict):
469
861
  if "comments" in existing_JSONGrapher_record: self.fig_dict["comments"] = existing_JSONGrapher_record["comments"]
470
862
  if "datatype" in existing_JSONGrapher_record: self.fig_dict["datatype"] = existing_JSONGrapher_record["datatype"]
471
863
  if "data" in existing_JSONGrapher_record: self.fig_dict["data"] = existing_JSONGrapher_record["data"]
472
864
  if "layout" in existing_JSONGrapher_record: self.fig_dict["layout"] = existing_JSONGrapher_record["layout"]
865
+ else:
866
+ self.fig_dict = existing_JSONGrapher_record.fig_dict
867
+
473
868
 
474
869
  #the below function takes in existin JSONGrpher record, and merges the data in.
475
870
  #This requires scaling any data as needed, according to units.
@@ -512,48 +907,28 @@ class JSONGrapherRecord:
512
907
 
513
908
  #the json object can be a filename string or can be json object which is actually a dictionary.
514
909
  def import_from_json(self, json_filename_or_object):
515
- if type(json_filename_or_object) == type(""): #assume it's a filename and path.
516
- # Open the file in read mode with UTF-8 encoding
517
- with open(json_filename_or_object, 'r', encoding='utf-8') as file:
518
- # Read the entire content of the file
519
- content = file.read()
520
- self.fig_dict = json.loads(content)
910
+ if type(json_filename_or_object) == type(""): #assume it's a json_string or filename_and_path.
911
+ try:
912
+ record = json.loads(json_filename_or_object) #first check if it's a json string.
913
+ except json.JSONDecodeError as e1: # Catch specific exception
914
+ try:
915
+ import os
916
+ #if the filename does not exist, then we'll check if adding ".json" fixes the problem.
917
+ if not os.path.exists(json_filename_or_object):
918
+ json_added_filename = json_filename_or_object + ".json"
919
+ if os.path.exists(json_added_filename): json_filename_or_object = json_added_filename #only change the filename if the json_filename exists.
920
+ # Open the file in read mode with UTF-8 encoding
921
+ with open(json_filename_or_object, "r", encoding="utf-8") as file:
922
+ # Read the entire content of the file
923
+ record = file.read().strip() # Stripping leading/trailing whitespace
924
+ self.fig_dict = json.loads(record)
925
+ return self.fig_dict
926
+ except json.JSONDecodeError as e2: # Catch specific exception
927
+ print(f"JSON loading failed on record: {record}. Error: {e1} when trying to parse as a json directly, and {e2} when trying to use as a filename. You may want to try opening your JSON file in VS Code or in an online JSON Validator. Does your json have double quotes around strings? Single quotes around strings is allowed in python, but disallowed in JSON specifications. You may also need to check how Booleans and other aspects are defined in JSON.") # Improved error reporting
521
928
  else:
522
929
  self.fig_dict = json_filename_or_object
930
+ return self.fig_dict
523
931
 
524
- def set_plot_type_one_data_series(self, data_series_index, plot_type):
525
- data_series_dict = self.fig_dict['data'][data_series_index]
526
- data_series_dict = set_data_series_dict_plot_type(data_series_dict=data_series_dict, plot_type=plot_type)
527
- #now put the data_series_dict back:
528
- self.fig_dict['data'][data_series_index] = data_series_dict
529
-
530
- def set_plot_type_all_series(self, plot_type):
531
- """
532
- Sets the plot_type field for the all data series.
533
- options are: scatter, spline, scatter_spline
534
- """
535
- self.plot_type = plot_type
536
- for data_series_index in range(len(self.fig_dict['data'])): #works with array indexing.
537
- self.set_plot_type_one_data_series(data_series_index, plot_type)
538
-
539
-
540
- def update_plot_types(self, plot_type=None):
541
- """
542
- updates the plot types for every existing data series.
543
-
544
- """
545
- #If optional argument not provided, take class instance setting.
546
- if plot_type == None:
547
- plot_type = self.plot_type
548
- #If the plot_type is not blank, use it for all series.
549
- if plot_type != "":
550
- self.set_plot_type_all_series(plot_type)
551
- else: #if the plot_type is blank, then we will go through each data series and update them individually.
552
- for data_series_index, data_series_dict in enumerate(self.fig_dict['data']):
553
- #This will update the data_series_dict as needed, putting a plot_type if there is not one.
554
- data_series_dict = set_data_series_dict_plot_type(data_series_dict=data_series_dict)
555
- self.fig_dict['data'][data_series_index] = data_series_dict
556
-
557
932
  def set_datatype(self, datatype):
558
933
  """
559
934
  Sets the datatype field used as the experiment type or schema identifier.
@@ -582,8 +957,9 @@ class JSONGrapherRecord:
582
957
  """
583
958
  if "xaxis" not in self.fig_dict['layout'] or not isinstance(self.fig_dict['layout'].get("xaxis"), dict):
584
959
  self.fig_dict['layout']["xaxis"] = {} # Initialize x-axis as a dictionary if it doesn't exist.
585
- validation_result, warnings_list, x_axis_label_including_units = validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=remove_plural_units)
586
- self.fig_dict['layout']["xaxis"]["title"]['text'] = x_axis_label_including_units
960
+ _validation_result, _warnings_list, x_axis_label_including_units = validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=remove_plural_units)
961
+ #setdefault avoids problems for missing fields.
962
+ self.fig_dict.setdefault("layout", {}).setdefault("xaxis", {}).setdefault("title", {})["text"] = x_axis_label_including_units
587
963
 
588
964
  def set_y_axis_label_including_units(self, y_axis_label_including_units, remove_plural_units=True):
589
965
  """
@@ -591,25 +967,37 @@ class JSONGrapherRecord:
591
967
  yaxis_title (str): The new title to set for the y-axis.
592
968
  """
593
969
  if "yaxis" not in self.fig_dict['layout'] or not isinstance(self.fig_dict['layout'].get("yaxis"), dict):
594
- self.fig_dict['layout']["yaxis"] = {} # Initialize y-axis as a dictionary if it doesn't exist.
595
-
596
- validation_result, warnings_list, y_axis_label_including_units = validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=remove_plural_units)
597
- self.fig_dict['layout']["yaxis"]["title"]['text'] = y_axis_label_including_units
598
-
970
+ self.fig_dict['layout']["yaxis"] = {} # Initialize y-axis as a dictionary if it doesn't exist.
971
+ _validation_result, _warnings_list, y_axis_label_including_units = validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=remove_plural_units)
972
+ #setdefault avoids problems for missing fields.
973
+ self.fig_dict.setdefault("layout", {}).setdefault("yaxis", {}).setdefault("title", {})["text"] = y_axis_label_including_units
974
+
975
+ def set_z_axis_label_including_units(self, z_axis_label_including_units, remove_plural_units=True):
976
+ """
977
+ Updates the title of the z-axis in the layout dictionary.
978
+ zaxis_title (str): The new title to set for the z-axis.
979
+ """
980
+ if "zaxis" not in self.fig_dict['layout'] or not isinstance(self.fig_dict['layout'].get("zaxis"), dict):
981
+ self.fig_dict['layout']["zaxis"] = {} # Initialize y-axis as a dictionary if it doesn't exist.
982
+ self.fig_dict['layout']["zaxis"]["title"] = {} # Initialize y-axis as a dictionary if it doesn't exist.
983
+ _validation_result, _warnings_list, z_axis_label_including_units = validate_JSONGrapher_axis_label(z_axis_label_including_units, axis_name="z", remove_plural_units=remove_plural_units)
984
+ #setdefault avoids problems for missing fields.
985
+ self.fig_dict.setdefault("layout", {}).setdefault("zaxis", {}).setdefault("title", {})["text"] = z_axis_label_including_units
986
+
599
987
  #function to set the min and max of the x axis in plotly way.
600
- def set_x_axis_range(self, min, max):
601
- self.fig_dict["layout"]["xaxis"][0] = min
602
- self.fig_dict["layout"]["xaxis"][1] = max
988
+ def set_x_axis_range(self, min_value, max_value):
989
+ self.fig_dict["layout"]["xaxis"][0] = min_value
990
+ self.fig_dict["layout"]["xaxis"][1] = max_value
603
991
  #function to set the min and max of the y axis in plotly way.
604
- def set_y_axis_range(self, min, max):
605
- self.fig_dict["layout"]["yaxis"][0] = min
606
- self.fig_dict["layout"]["yaxis"][1] = max
992
+ def set_y_axis_range(self, min_value, max_value):
993
+ self.fig_dict["layout"]["yaxis"][0] = min_value
994
+ self.fig_dict["layout"]["yaxis"][1] = max_value
607
995
 
608
996
  #function to scale the values in the data series by arbitrary amounts.
609
997
  def scale_record(self, num_to_scale_x_values_by = 1, num_to_scale_y_values_by = 1):
610
998
  self.fig_dict = scale_fig_dict_values(self.fig_dict, num_to_scale_x_values_by=num_to_scale_x_values_by, num_to_scale_y_values_by=num_to_scale_y_values_by)
611
999
 
612
- def set_layout(self, comments="", graph_title="", x_axis_label_including_units="", y_axis_label_including_units="", x_axis_comments="",y_axis_comments="", remove_plural_units=True):
1000
+ def set_layout_fields(self, comments="", graph_title="", x_axis_label_including_units="", y_axis_label_including_units="", x_axis_comments="",y_axis_comments="", remove_plural_units=True):
613
1001
  # comments: General comments about the layout. Allowed by JSONGrapher, but will be removed if converted to a plotly object.
614
1002
  # graph_title: Title of the graph.
615
1003
  # xaxis_title: Title of the x-axis, including units.
@@ -617,13 +1005,12 @@ class JSONGrapherRecord:
617
1005
  # yaxis_title: Title of the y-axis, including units.
618
1006
  # yaxis_comments: Comments related to the y-axis. Allowed by JSONGrapher, but will be removed if converted to a plotly object.
619
1007
 
620
- validation_result, warnings_list, x_axis_label_including_units = validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=remove_plural_units)
621
- validation_result, warnings_list, y_axis_label_including_units = validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=remove_plural_units)
1008
+ _validation_result, _warnings_list, x_axis_label_including_units = validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=remove_plural_units)
1009
+ _validation_result, _warnings_list, y_axis_label_including_units = validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=remove_plural_units)
622
1010
  self.fig_dict['layout']["title"]['text'] = graph_title
623
1011
  self.fig_dict['layout']["xaxis"]["title"]['text'] = x_axis_label_including_units
624
1012
  self.fig_dict['layout']["yaxis"]["title"]['text'] = y_axis_label_including_units
625
1013
 
626
-
627
1014
  #populate any optional fields, if provided:
628
1015
  if len(comments) > 0:
629
1016
  self.fig_dict['layout']["comments"] = comments
@@ -631,14 +1018,13 @@ class JSONGrapherRecord:
631
1018
  self.fig_dict['layout']["xaxis"]["comments"] = x_axis_comments
632
1019
  if len(y_axis_comments) > 0:
633
1020
  self.fig_dict['layout']["yaxis"]["comments"] = y_axis_comments
634
-
635
-
636
1021
  return self.fig_dict['layout']
637
1022
 
638
1023
  #This function validates the output before exporting, and also has an option of removing hints.
639
1024
  #The update_and_validate function will clean for plotly.
640
1025
  #simulate all series will simulate any series as needed.
641
- def export_to_json_file(self, filename, update_and_validate=True, validate=True, simulate_all_series = True, remove_simulate_fields= False, remove_remaining_hints=False):
1026
+ #TODO: need to add an "include_formatting" option
1027
+ def export_to_json_file(self, filename, update_and_validate=True, validate=True, simulate_all_series = True, remove_simulate_fields= False, remove_equation_fields= False, remove_remaining_hints=False):
642
1028
  """
643
1029
  writes the json to a file
644
1030
  returns the json as a dictionary.
@@ -652,6 +1038,8 @@ class JSONGrapherRecord:
652
1038
  self.fig_dict = simulate_as_needed_in_fig_dict(self.fig_dict)
653
1039
  if remove_simulate_fields == True:
654
1040
  self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['simulate'])
1041
+ if remove_equation_fields == True:
1042
+ self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['equation'])
655
1043
  if remove_remaining_hints == True:
656
1044
  self.remove_hints()
657
1045
  if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
@@ -670,26 +1058,66 @@ class JSONGrapherRecord:
670
1058
  return self.fig_dict
671
1059
 
672
1060
  #simulate all series will simulate any series as needed.
673
- def get_plotly_fig(self, simulate_all_series = True, update_and_validate=True):
1061
+ def get_plotly_fig(self, plot_style=None, update_and_validate=True, simulate_all_series=True, evaluate_all_equations=True, adjust_implicit_data_ranges=True):
1062
+ """
1063
+ Generates a Plotly figure from the stored fig_dict, performing simulations and equations as needed.
1064
+ By default, it will apply the default still hard coded into jsongrapher.
1065
+
1066
+ Args:
1067
+ plot_style: String or dictionary of style to apply. Use '' to skip applying a style, or provide a list of length two containing both a layout style and a data series style."none" removes all style.
1068
+ simulate_all_series (bool): If True, performs simulations for applicable series.
1069
+ update_and_validate (bool): If True, applies automatic corrections to fig_dict.
1070
+ evaluate_all_equations (bool): If True, evaluates all equation-based series.
1071
+ adjust_implicit_data_ranges (bool): If True, modifies ranges for implicit data series.
1072
+
1073
+ Returns:
1074
+ plotly Figure: A validated Plotly figure object based on fig_dict.
1075
+ """
1076
+ if plot_style is None: #should not initialize mutable objects in arguments line, so doing here.
1077
+ plot_style = {"layout_style": "", "trace_styles_collection": ""} # Fresh dictionary per function call
1078
+
674
1079
  import plotly.io as pio
675
1080
  import copy
676
- if simulate_all_series == True:
677
- self.fig_dict = simulate_as_needed_in_fig_dict(self.fig_dict)
678
- original_fig_dict = copy.deepcopy(self.fig_dict) #we will get a copy, because otherwise the original fig_dict will be forced to be overwritten.
679
- #if simulate_all_series is true, we'll try to simulate any series that need it, then clean the simulate fields out.
1081
+ if plot_style == {"layout_style":"", "trace_styles_collection":""}: #if the plot_style received is the default, we'll check if the fig_dict has a plot_style.
1082
+ plot_style = self.fig_dict.get("plot_style", {"layout_style":"", "trace_styles_collection":""}) #retrieve from self.fig_dict, and use default if not there.
1083
+ #This code *does not* simply modify self.fig_dict. It creates a deepcopy and then puts the final x y data back in.
1084
+ self.fig_dict = execute_implicit_data_series_operations(self.fig_dict,
1085
+ simulate_all_series=simulate_all_series,
1086
+ evaluate_all_equations=evaluate_all_equations,
1087
+ adjust_implicit_data_ranges=adjust_implicit_data_ranges)
1088
+ #Regardless of implicit data series, we make a fig_dict copy, because we will clean self.fig_dict for creating the new plotting fig object.
1089
+ original_fig_dict = copy.deepcopy(self.fig_dict)
1090
+ #before cleaning and validating, we'll apply styles.
1091
+ plot_style = parse_plot_style(plot_style=plot_style)
1092
+ self.apply_plot_style(plot_style=plot_style)
1093
+ #Now we clean out the fields and make a plotly object.
680
1094
  if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
681
- self.update_and_validate_JSONGrapher_record()
682
- self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['simulate', 'custom_units_chevrons'])
1095
+ self.update_and_validate_JSONGrapher_record() #this is the line that cleans "self.fig_dict"
1096
+ self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['simulate', 'custom_units_chevrons', 'equation', 'trace_style', '3d_axes', 'bubble'])
683
1097
  fig = pio.from_json(json.dumps(self.fig_dict))
684
- self.fig_dict = original_fig_dict #restore the original fig_dict.
1098
+ #restore the original fig_dict.
1099
+ self.fig_dict = original_fig_dict
685
1100
  return fig
686
1101
 
687
- #simulate all series will simulate any series as needed.
688
- def plot_with_plotly(self, simulate_all_series = True, update_and_validate=True):
689
- fig = self.get_plotly_fig(simulate_all_series = simulate_all_series, update_and_validate=update_and_validate)
1102
+ #Just a wrapper aroudn plot_with_plotly.
1103
+ def plot(self, plot_style = None, update_and_validate=True, simulate_all_series=True, evaluate_all_equations=True, adjust_implicit_data_ranges=True):
1104
+ if plot_style is None: #should not initialize mutable objects in arguments line, so doing here.
1105
+ plot_style = {"layout_style": "", "trace_styles_collection": ""} # Fresh dictionary per function call
1106
+ return self.plot_with_plotly(plot_style=plot_style, update_and_validate=update_and_validate, simulate_all_series=simulate_all_series, evaluate_all_equations=evaluate_all_equations, adjust_implicit_data_ranges=adjust_implicit_data_ranges)
1107
+
1108
+ #simulate all series will simulate any series as needed. If changing this function's arguments, also change those for self.plot()
1109
+ def plot_with_plotly(self, plot_style = None, update_and_validate=True, simulate_all_series=True, evaluate_all_equations=True, adjust_implicit_data_ranges=True):
1110
+ if plot_style is None: #should not initialize mutable objects in arguments line, so doing here.
1111
+ plot_style = {"layout_style": "", "trace_styles_collection": ""} # Fresh dictionary per function call
1112
+ fig = self.get_plotly_fig(plot_style=plot_style,
1113
+ simulate_all_series=simulate_all_series,
1114
+ update_and_validate=update_and_validate,
1115
+ evaluate_all_equations=evaluate_all_equations,
1116
+ adjust_implicit_data_ranges=adjust_implicit_data_ranges)
690
1117
  fig.show()
691
1118
  #No need for fig.close() for plotly figures.
692
1119
 
1120
+
693
1121
  #simulate all series will simulate any series as needed.
694
1122
  def export_to_plotly_png(self, filename, simulate_all_series = True, update_and_validate=True, timeout=10):
695
1123
  fig = self.get_plotly_fig(simulate_all_series = simulate_all_series, update_and_validate=update_and_validate)
@@ -707,7 +1135,7 @@ class JSONGrapherRecord:
707
1135
  def export():
708
1136
  try:
709
1137
  fig.write_image(filename, engine="kaleido")
710
- except Exception as e:
1138
+ except Exception as e: # This is so VS code pylint does not flag this line. pylint: disable=broad-except
711
1139
  print(f"Export failed: {e}")
712
1140
 
713
1141
  import threading
@@ -720,23 +1148,48 @@ class JSONGrapherRecord:
720
1148
  #update_and_validate will 'clean' for plotly.
721
1149
  #In the case of creating a matplotlib figure, this really just means removing excess fields.
722
1150
  #simulate all series will simulate any series as needed.
723
- def get_matplotlib_fig(self, simulate_all_series = True, update_and_validate=True):
1151
+ def get_matplotlib_fig(self, plot_style = None, update_and_validate=True, simulate_all_series = True, evaluate_all_equations = True, adjust_implicit_data_ranges=True):
1152
+ """
1153
+ Generates a matplotlib figure from the stored fig_dict, performing simulations and equations as needed.
1154
+
1155
+ Args:
1156
+ simulate_all_series (bool): If True, performs simulations for applicable series.
1157
+ update_and_validate (bool): If True, applies automatic corrections to fig_dict.
1158
+ evaluate_all_equations (bool): If True, evaluates all equation-based series.
1159
+ adjust_implicit_data_ranges (bool): If True, modifies ranges for implicit data series.
1160
+
1161
+ Returns:
1162
+ plotly Figure: A validated matplotlib figure object based on fig_dict.
1163
+ """
1164
+ if plot_style is None: #should not initialize mutable objects in arguments line, so doing here.
1165
+ plot_style = {"layout_style": "", "trace_styles_collection": ""} # Fresh dictionary per function call
724
1166
  import copy
725
- #if simulate_all_series is true, we'll try to simulate any series that need it, then clean the simulate fields out.
726
- if simulate_all_series == True:
727
- self.fig_dict = simulate_as_needed_in_fig_dict(self.fig_dict)
1167
+ if plot_style == {"layout_style":"", "trace_styles_collection":""}: #if the plot_style received is the default, we'll check if the fig_dict has a plot_style.
1168
+ plot_style = self.fig_dict.get("plot_style", {"layout_style":"", "trace_styles_collection":""})
1169
+ #This code *does not* simply modify self.fig_dict. It creates a deepcopy and then puts the final x y data back in.
1170
+ self.fig_dict = execute_implicit_data_series_operations(self.fig_dict,
1171
+ simulate_all_series=simulate_all_series,
1172
+ evaluate_all_equations=evaluate_all_equations,
1173
+ adjust_implicit_data_ranges=adjust_implicit_data_ranges)
1174
+ #Regardless of implicit data series, we make a fig_dict copy, because we will clean self.fig_dict for creating the new plotting fig object.
728
1175
  original_fig_dict = copy.deepcopy(self.fig_dict) #we will get a copy, because otherwise the original fig_dict will be forced to be overwritten.
1176
+ #before cleaning and validating, we'll apply styles.
1177
+ plot_style = parse_plot_style(plot_style=plot_style)
1178
+ self.apply_plot_style(plot_style=plot_style)
729
1179
  if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
730
1180
  self.update_and_validate_JSONGrapher_record()
731
- self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['simulate', 'custom_units_chevrons'])
1181
+ self.fig_dict = clean_json_fig_dict(self.fig_dict, fields_to_update=['simulate', 'custom_units_chevrons', 'equation', 'trace_style'])
732
1182
  fig = convert_JSONGrapher_dict_to_matplotlib_fig(self.fig_dict)
733
1183
  self.fig_dict = original_fig_dict #restore the original fig_dict.
734
1184
  return fig
735
1185
 
736
1186
  #simulate all series will simulate any series as needed.
737
- def plot_with_matplotlib(self, simulate_all_series = True, update_and_validate=True):
1187
+ def plot_with_matplotlib(self, update_and_validate=True, simulate_all_series=True, evaluate_all_equations=True, adjust_implicit_data_ranges=True):
738
1188
  import matplotlib.pyplot as plt
739
- fig = self.get_matplotlib_fig(simulate_all_series = simulate_all_series, update_and_validate=update_and_validate)
1189
+ fig = self.get_matplotlib_fig(simulate_all_series=simulate_all_series,
1190
+ update_and_validate=update_and_validate,
1191
+ evaluate_all_equations=evaluate_all_equations,
1192
+ adjust_implicit_data_ranges=adjust_implicit_data_ranges)
740
1193
  plt.show()
741
1194
  plt.close(fig) #remove fig from memory.
742
1195
 
@@ -811,9 +1264,130 @@ class JSONGrapherRecord:
811
1264
  if current_field.get(current_path_key, "") == hint_text:
812
1265
  current_field[current_path_key] = ""
813
1266
 
1267
+ ## Start of section of JSONGRapher class functions related to styles ##
1268
+
1269
+ def apply_plot_style(self, plot_style= None):
1270
+ #the plot_style can be a string, or a plot_style dictionary {"layout_style":"default", "trace_styles_collection":"default"} or a list of length two with those two items.
1271
+ #The plot_style dictionary can include a pair of dictionaries.
1272
+ #if apply style is called directly, we will first put the plot_style into the plot_style field
1273
+ #This way, the style will stay.
1274
+ if plot_style is None: #should not initialize mutable objects in arguments line, so doing here.
1275
+ plot_style = {"layout_style": "", "trace_styles_collection": ""} # Fresh dictionary per function call
1276
+ self.fig_dict['plot_style'] = plot_style
1277
+ self.fig_dict = apply_plot_style_to_plotly_dict(self.fig_dict, plot_style=plot_style)
1278
+ def remove_plot_style(self):
1279
+ self.fig_dict.pop("plot_style") #This line removes the field of plot_style from the fig_dict.
1280
+ self.fig_dict = remove_plot_style_from_plotly_dict(self.fig_dict) #This line removes the actual formatting from the fig_dict.
1281
+ def set_layout_style(self, layout_style):
1282
+ if "plot_style" not in self.fig_dict: #create it not present.
1283
+ self.fig_dict["plot_style"] = {} # Initialize if missing
1284
+ self.fig_dict["plot_style"]["layout_style"] = layout_style
1285
+ def remove_layout_style_setting(self):
1286
+ if "layout_style" in self.fig_dict["plot_style"]:
1287
+ self.fig_dict["plot_style"].pop("layout_style")
1288
+ def extract_layout_style(self):
1289
+ layout_style = extract_layout_style_from_plotly_dict(self.fig_dict)
1290
+ return layout_style
1291
+ def apply_trace_style_by_index(self, data_series_index, trace_styles_collection='', trace_style=''):
1292
+ if trace_styles_collection == '':
1293
+ self.fig_dict.setdefault("plot_style",{}) #create the plot_style dictionary if it's not there. Else, return current value.
1294
+ trace_styles_collection = self.fig_dict["plot_style"].get("trace_styles_collection", '') #check if there is a trace_styles_collection within it, and use that. If it's not there, then use ''.
1295
+ #trace_style should be a dictionary, but can be a string.
1296
+ data_series = self.fig_dict["data"][data_series_index]
1297
+ data_series = apply_trace_style_to_single_data_series(data_series, trace_styles_collection=trace_styles_collection, trace_style_to_apply=trace_style) #this is the 'external' function, not the one in the class.
1298
+ self.fig_dict["data"][data_series_index] = data_series
1299
+ return data_series
1300
+ def set_trace_style_one_data_series(self, data_series_index, trace_style):
1301
+ self.fig_dict['data'][data_series_index]["trace_style"] = trace_style
1302
+ return self.fig_dict['data'][data_series_index]
1303
+ def set_trace_styles_collection(self, trace_styles_collection):
1304
+ """
1305
+ Sets the plot_style["trace_styles_collection"] field for the all data series.
1306
+ options are: scatter, spline, scatter_spline
1307
+ """
1308
+ self.fig_dict["plot_style"]["trace_styles_collection"] = trace_styles_collection
1309
+ def remove_trace_styles_collection_setting(self):
1310
+ if "trace_styles_collection" in self.fig_dict["plot_style"]:
1311
+ self.fig_dict["plot_style"].pop("trace_styles_collection")
1312
+ def set_trace_style_all_series(self, trace_style):
1313
+ """
1314
+ Sets the trace_style field for the all data series.
1315
+ options are: scatter, spline, scatter_spline
1316
+ """
1317
+ for data_series_index in range(len(self.fig_dict['data'])): #works with array indexing.
1318
+ self.set_trace_style_one_data_series(data_series_index, trace_style)
1319
+ def extract_trace_styles_collection(self, new_trace_styles_collection_name='',
1320
+ indices_of_data_series_to_extract_styles_from=None,
1321
+ new_trace_style_names_list=None, extract_colors=False):
1322
+ """
1323
+ Extracts trace style collection
1324
+ :param new_trace_styles_collection_name: str, Name of the new collection.
1325
+ :param indices_of_data_series_to_extract_styles_from: list, Indices of series to extract styles from.
1326
+ :param new_trace_style_names_list: list, Names for the new trace styles.
1327
+ """
1328
+ if indices_of_data_series_to_extract_styles_from is None: # should not initialize mutable objects in arguments line, so doing here.
1329
+ indices_of_data_series_to_extract_styles_from = []
1330
+ if new_trace_style_names_list is None: # should not initialize mutable objects in arguments line, so doing here.
1331
+ new_trace_style_names_list = []
1332
+ fig_dict = self.fig_dict
1333
+ new_trace_styles_collection_dictionary_without_name = {}
1334
+ if new_trace_styles_collection_name == '':
1335
+ new_trace_styles_collection_name = 'replace_this_with_your_trace_styles_collection_name'
1336
+ if indices_of_data_series_to_extract_styles_from == []:
1337
+ indices_of_data_series_to_extract_styles_from = range(len(fig_dict["data"]))
1338
+ if new_trace_style_names_list == []:
1339
+ for data_series_index in indices_of_data_series_to_extract_styles_from:
1340
+ data_series_dict = fig_dict["data"][data_series_index]
1341
+ trace_style_name = data_series_dict.get('trace_style', '') # return blank line if not there.
1342
+ if trace_style_name == '':
1343
+ trace_style_name = 'custom_trace_style' + str(data_series_index)
1344
+ if trace_style_name not in new_trace_style_names_list:
1345
+ pass
1346
+ else:
1347
+ trace_style_name = trace_style_name + str(data_series_index)
1348
+ new_trace_style_names_list.append(trace_style_name)
1349
+ if len(indices_of_data_series_to_extract_styles_from) != len(new_trace_style_names_list):
1350
+ raise ValueError("Error: The input for indices_of_data_series_to_extract_styles_from is not compatible with the input for new_trace_style_names_list. There is a difference in lengths after the automatic parsing and filling that occurs.")
1351
+ for index_to_extract_from in indices_of_data_series_to_extract_styles_from:
1352
+ new_trace_style_name = new_trace_style_names_list[index_to_extract_from]
1353
+ extracted_trace_style = extract_trace_style_by_index(fig_dict, index_to_extract_from, new_trace_style_name=new_trace_style_names_list[index_to_extract_from], extract_colors=extract_colors)
1354
+ new_trace_styles_collection_dictionary_without_name[new_trace_style_name] = extracted_trace_style[new_trace_style_name]
1355
+ return new_trace_styles_collection_name, new_trace_styles_collection_dictionary_without_name
1356
+ def export_trace_styles_collection(self, new_trace_styles_collection_name='',
1357
+ indices_of_data_series_to_extract_styles_from=None,
1358
+ new_trace_style_names_list=None, filename='', extract_colors=False):
1359
+ """
1360
+ Exports trace style collection while ensuring proper handling of mutable default arguments.
1361
+
1362
+ :param new_trace_styles_collection_name: str, Name of the new collection.
1363
+ :param indices_of_data_series_to_extract_styles_from: list, Indices of series to extract styles from.
1364
+ :param new_trace_style_names_list: list, Names for the new trace styles.
1365
+ :param filename: str, Name of the file to export to.
1366
+ """
1367
+ if indices_of_data_series_to_extract_styles_from is None: # should not initialize mutable objects in arguments line, so doing here.
1368
+ indices_of_data_series_to_extract_styles_from = []
1369
+ if new_trace_style_names_list is None: # should not initialize mutable objects in arguments line, so doing here.
1370
+ new_trace_style_names_list = []
1371
+ auto_new_trace_styles_collection_name, new_trace_styles_collection_dictionary_without_name = self.extract_trace_styles_collection(new_trace_styles_collection_name=new_trace_styles_collection_name, indices_of_data_series_to_extract_styles_from=indices_of_data_series_to_extract_styles_from, new_trace_style_names_list = new_trace_style_names_list, extract_colors=extract_colors)
1372
+ if new_trace_styles_collection_name == '':
1373
+ new_trace_styles_collection_name = auto_new_trace_styles_collection_name
1374
+ if filename == '':
1375
+ filename = new_trace_styles_collection_name
1376
+ write_trace_styles_collection_to_file(trace_styles_collection=new_trace_styles_collection_dictionary_without_name, trace_styles_collection_name=new_trace_styles_collection_name, filename=filename)
1377
+ return new_trace_styles_collection_name, new_trace_styles_collection_dictionary_without_name
1378
+ def extract_trace_style_by_index(self, data_series_index, new_trace_style_name='', extract_colors=False):
1379
+ extracted_trace_style = extract_trace_style_by_index(self.fig_dict, data_series_index, new_trace_style_name=new_trace_style_name, extract_colors=extract_colors)
1380
+ return extracted_trace_style
1381
+ def export_trace_style_by_index(self, data_series_index, new_trace_style_name='', filename='', extract_colors=False):
1382
+ extracted_trace_style = extract_trace_style_by_index(self.fig_dict, data_series_index, new_trace_style_name=new_trace_style_name, extract_colors=extract_colors)
1383
+ new_trace_style_name = list(extracted_trace_style.keys())[0] #the extracted_trace_style will have a single key which is the style name.
1384
+ if filename == '':
1385
+ filename = new_trace_style_name
1386
+ write_trace_style_to_file(trace_style_dict=extracted_trace_style[new_trace_style_name],trace_style_name=new_trace_style_name, filename=filename)
1387
+ return extracted_trace_style
1388
+ ## End of section of JSONGRapher class functions related to styles ##
1389
+
814
1390
  #Make some pointers to external functions, for convenience, so people can use syntax like record.function_name() if desired.
815
- def apply_style(self, style_name):
816
- self.fig_dict = apply_style_to_plotly_dict(self.fig_dict, style_name=style_name)
817
1391
  def validate_JSONGrapher_record(self):
818
1392
  validate_JSONGrapher_record(self)
819
1393
  def update_and_validate_JSONGrapher_record(self):
@@ -877,16 +1451,16 @@ def units_plural_removal(units_to_check):
877
1451
  - "changed" (Boolean): True, or False, where True means the string was changed to remove an "s" at the end.
878
1452
  - "singularized" (string): The units parsed to be singular, if needed.
879
1453
  """
880
- #Check if we have the module we need. If not, return with no change.
1454
+ # Check if we have the module we need. If not, return with no change.
881
1455
  try:
882
1456
  import JSONGrapher.units_list as units_list
883
- except:
884
- #if JSONGrapher is not present, try getting the units_list file locally.
1457
+ except ImportError:
885
1458
  try:
886
- import units_list
887
- except:#if still not present, give up and avoid crashing.
1459
+ from . import units_list # Attempt local import
1460
+ except ImportError as exc: # If still not present, give up and avoid crashing
888
1461
  units_changed_flag = False
889
- return units_changed_flag, units_to_check #return None if there was no test.
1462
+ print(f"Module import failed: {exc}") # Log the error for debugging
1463
+ return units_changed_flag, units_to_check # Return unchanged values
890
1464
 
891
1465
  #First try to check if units are blank or ends with "s" is in the units list.
892
1466
  if (units_to_check == "") or (units_to_check[-1] != "s"):
@@ -904,41 +1478,51 @@ def units_plural_removal(units_to_check):
904
1478
  else: #No change if the truncated string isn't found.
905
1479
  units_changed_flag = False
906
1480
  units_singularized = units_to_check
1481
+ else:
1482
+ units_changed_flag = False
1483
+ units_singularized = units_to_check #if it's outside of ourknown logic, we just return unchanged.
907
1484
  return units_changed_flag, units_singularized
908
1485
 
909
1486
 
910
1487
  def separate_label_text_from_units(label_with_units):
911
- """
912
- Parses a label with text string and units in parentheses after that to return the two parts.
913
- This is not meant to separate strings like "Time (s)", it is not meant for strings like "5 (kg)"
1488
+ # Check for mismatched parentheses
1489
+ open_parentheses = label_with_units.count('(')
1490
+ close_parentheses = label_with_units.count(')')
1491
+
1492
+ if open_parentheses != close_parentheses:
1493
+ raise ValueError(f"Mismatched parentheses in input string: '{label_with_units}'")
914
1494
 
915
- Args:
916
- value (str): A string containing a label and optional units enclosed in parentheses.
917
- Example: "Time (Years)" or "Speed (km/s)
1495
+ # Default parsed output
1496
+ parsed_output = {"text": label_with_units, "units": ""}
918
1497
 
919
- Returns:
920
- dict: A dictionary with two keys:
921
- - "text" (str): The label text parsed from the input string.
922
- - "units" (str): The units parsed from the input string, or an empty string if no units are present.
923
- """
924
- # Find the position of the first '(' and the last ')'
1498
+ # Extract tentative start and end indices, from first open and first close parentheses.
925
1499
  start = label_with_units.find('(')
926
1500
  end = label_with_units.rfind(')')
927
-
928
- # Ensure both are found and properly ordered
929
- if start != -1 and end != -1 and end > start:
930
- text_part = label_with_units[:start].strip() # Everything before '('
931
- units_part = label_with_units[start + 1:end].strip() # Everything inside '()'
1501
+
1502
+ # Flag to track if the second check fails
1503
+ second_check_failed = False
1504
+
1505
+ # Ensure removing both first '(' and last ')' doesn't cause misalignment
1506
+ if start != -1 and end != -1:
1507
+ temp_string = label_with_units[:start] + label_with_units[start + 1:end] + label_with_units[end + 1:] # Removing first '(' and last ')'
1508
+ first_closing_paren_after_removal = temp_string.find(')')
1509
+ first_opening_paren_after_removal = temp_string.find('(')
1510
+ if first_opening_paren_after_removal != -1 and first_closing_paren_after_removal < first_opening_paren_after_removal:
1511
+ second_check_failed = True # Set flag if second check fails
1512
+
1513
+ if second_check_failed:
1514
+ #For the units, keep everything from the first '(' onward
1515
+ parsed_output["text"] = label_with_units[:start].strip()
1516
+ parsed_output["units"] = label_with_units[start:].strip()
932
1517
  else:
933
- text_part = label_with_units
934
- units_part = ""
935
- parsed_output = {
936
- "text":text_part,
937
- "units":units_part
938
- }
1518
+ # Extract everything between first '(' and last ')'
1519
+ parsed_output["text"] = label_with_units[:start].strip()
1520
+ parsed_output["units"] = label_with_units[start + 1:end].strip()
1521
+
939
1522
  return parsed_output
940
1523
 
941
1524
 
1525
+
942
1526
  def validate_plotly_data_list(data):
943
1527
  """
944
1528
  Validates the entries in a Plotly data array.
@@ -973,29 +1557,29 @@ def validate_plotly_data_list(data):
973
1557
  if "comments" in trace:
974
1558
  warnings_list.append(f"Trace {i} has a comments field within the data. This is allowed by JSONGrapher, but is discouraged by plotly. By default, this will be removed when you export your record.")
975
1559
  # Determine the type based on the fields provided
976
- trace_type = trace.get("type")
977
- if not trace_type:
1560
+ trace_style = trace.get("type")
1561
+ if not trace_style:
978
1562
  # Infer type based on fields and attributes
979
1563
  if "x" in trace and "y" in trace:
980
1564
  if "mode" in trace or "marker" in trace or "line" in trace:
981
- trace_type = "scatter"
1565
+ trace_style = "scatter"
982
1566
  elif "text" in trace or "marker.color" in trace:
983
- trace_type = "bar"
1567
+ trace_style = "bar"
984
1568
  else:
985
- trace_type = "scatter" # Default assumption
1569
+ trace_style = "scatter" # Default assumption
986
1570
  elif "labels" in trace and "values" in trace:
987
- trace_type = "pie"
1571
+ trace_style = "pie"
988
1572
  elif "z" in trace:
989
- trace_type = "heatmap"
1573
+ trace_style = "heatmap"
990
1574
  else:
991
1575
  warnings_list.append(f"Trace {i} cannot be inferred as a valid type.")
992
1576
  continue
993
1577
 
994
1578
  # Check for required fields
995
- required_fields = required_fields_by_type.get(trace_type, [])
1579
+ required_fields = required_fields_by_type.get(trace_style, [])
996
1580
  for field in required_fields:
997
1581
  if field not in trace:
998
- warnings_list.append(f"Trace {i} (type inferred as {trace_type}) is missing required field: {field}.")
1582
+ warnings_list.append(f"Trace {i} (type inferred as {trace_style}) is missing required field: {field}.")
999
1583
 
1000
1584
  if warnings_list:
1001
1585
  print("Warning: There are some entries in your data list that did not pass validation checks: \n", warnings_list)
@@ -1021,7 +1605,6 @@ def parse_units(value):
1021
1605
  # Find the position of the first '(' and the last ')'
1022
1606
  start = value.find('(')
1023
1607
  end = value.rfind(')')
1024
- print("line 727", value)
1025
1608
  # Ensure both are found and properly ordered
1026
1609
  if start != -1 and end != -1 and end > start:
1027
1610
  number_part = value[:start].strip() # Everything before '('
@@ -1038,75 +1621,9 @@ def parse_units(value):
1038
1621
 
1039
1622
  return parsed_output
1040
1623
 
1041
-
1042
- #This function sets the plot_type of a data_series_dict
1043
- #based on some JSONGrapher options.
1044
- #It calls "plot_type_to_field_values"
1045
- #and then updates the data_series_dict accordingly, as needed.
1046
- def set_data_series_dict_plot_type(data_series_dict, plot_type=""):
1047
- if plot_type == "":
1048
- plot_type = data_series_dict.get('type', 'scatter') #get will return the second argument if the first argument is not present.
1049
- #We need to be careful about one case: in plotly, a "spline" is declared a scatter plot with data.line.shape = spline.
1050
- #So we need to check if we have spline set, in which case we make the plot_type scatter_spline when calling plot_type_to_field_values.
1051
- shape_field = data_series_dict.get('line', {}).get('shape', '') #get will return first argument if there, second if not, so can chain things.
1052
- #TODO: need to distinguish between "spline" and "scatter_spline" by checking for marker instructions.
1053
- if shape_field == 'spline':
1054
- plot_type = 'scatter_spline'
1055
- if shape_field == 'linear':
1056
- plot_type = 'scatter_line'
1057
- fields_dict = plot_type_to_field_values(plot_type)
1058
-
1059
-
1060
- #update the data_series_dict.
1061
- if fields_dict.get("mode_field"):
1062
- data_series_dict["mode"] = fields_dict["mode_field"]
1063
- if fields_dict.get("type_field"):
1064
- data_series_dict["type"] = fields_dict["type_field"]
1065
- if fields_dict.get("line_shape_field") != "":
1066
- data_series_dict.setdefault("line", {"shape": ''}) # Creates the field if it does not already exist.
1067
- data_series_dict["line"]["shape"] = fields_dict["line_shape_field"]
1068
- return data_series_dict
1069
-
1070
- #This function creates a fields_dict for the function set_data_series_dict_plot_type
1071
- def plot_type_to_field_values(plot_type):
1072
- """
1073
- Takes in a string that is a plot type, such as "scatter", "scatter_spline", etc.
1074
- and returns the field values that would have to go into a plotly data object.
1075
-
1076
- Returns:
1077
- dict: A dictionary with keys and values for the fields that will be ultimately filled.
1078
-
1079
- To these fields are used in the function set_plot_type_one_data_series
1080
-
1081
- """
1082
- fields_dict = {}
1083
- #initialize some variables.
1084
- fields_dict["type_field"] = plot_type.lower()
1085
- fields_dict["mode_field"] = None
1086
- fields_dict["line_shape_field"] = None
1087
- # Assign the various types. This list of values was determined 'manually'.
1088
- if plot_type.lower() == ("scatter" or "markers"):
1089
- fields_dict["type_field"] = "scatter"
1090
- fields_dict["mode_field"] = "markers"
1091
- fields_dict["line_shape_field"] = None
1092
- elif plot_type.lower() == "scatter_spline":
1093
- fields_dict["type_field"] = "scatter"
1094
- fields_dict["mode_field"] = None
1095
- fields_dict["line_shape_field"] = "spline"
1096
- elif plot_type.lower() == "spline":
1097
- fields_dict["type_field"] = 'scatter'
1098
- fields_dict["mode_field"] = 'lines'
1099
- fields_dict["line_shape_field"] = "spline"
1100
- elif plot_type.lower() == "scatter_line":
1101
- fields_dict["type_field"] = 'scatter'
1102
- fields_dict["mode_field"] = 'lines'
1103
- fields_dict["line_shape_field"] = "linear"
1104
- return fields_dict
1105
-
1106
1624
  #This function does updating of internal things before validating
1107
1625
  #This is used before printing and returning the JSON record.
1108
1626
  def update_and_validate_JSONGrapher_record(record, clean_for_plotly=True):
1109
- record.update_plot_types()
1110
1627
  record.validate_JSONGrapher_record()
1111
1628
  if clean_for_plotly == True:
1112
1629
  record.fig_dict = clean_json_fig_dict(record.fig_dict)
@@ -1202,41 +1719,150 @@ def validate_JSONGrapher_record(record):
1202
1719
  else:
1203
1720
  return True, []
1204
1721
 
1205
- def rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2):
1722
+ def rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2, num_interpolated_points=0, adjust_edges=True):
1206
1723
  """
1207
- Applies a rolling polynomial regression with a specified window size and degree.
1724
+ Applies a rolling polynomial regression with a specified window size and degree,
1725
+ interpolates additional points, and optionally adjusts edge points for smoother transitions.
1208
1726
 
1209
1727
  Args:
1210
1728
  x_values (list): List of x coordinates.
1211
1729
  y_values (list): List of y coordinates.
1212
1730
  window_size (int): Number of points per rolling fit (default: 3).
1213
1731
  degree (int): Degree of polynomial to fit (default: 2).
1732
+ num_interpolated_points (int): Number of interpolated points per segment (default: 3). Set to 0 to only return original points.
1733
+ adjust_edges (bool): Whether to adjust edge cases based on window size (default: True).
1214
1734
 
1215
1735
  Returns:
1216
1736
  tuple: (smoothed_x, smoothed_y) lists for plotting.
1217
1737
  """
1218
1738
  import numpy as np
1739
+
1219
1740
  smoothed_y = []
1220
- smoothed_x = x_values # Keep x values unchanged
1741
+ smoothed_x = []
1221
1742
 
1222
1743
  half_window = window_size // 2 # Number of points to take before & after
1223
1744
 
1224
- for i in range(len(y_values)):
1225
- # Handle edge cases: First and last points have fewer neighbors
1745
+ for i in range(len(y_values) - 1):
1746
+ # Handle edge cases dynamically based on window size
1226
1747
  left_bound = max(0, i - half_window)
1227
1748
  right_bound = min(len(y_values), i + half_window + 1)
1228
1749
 
1750
+ if adjust_edges:
1751
+ if i == 0: # First point
1752
+ right_bound = min(len(y_values), i + window_size) # Expand to use more points near start
1753
+ elif i == len(y_values) - 2: # Last point
1754
+ left_bound = max(0, i - (window_size - 1)) # Expand to include more points near end
1755
+
1229
1756
  # Select the windowed data
1230
1757
  x_window = np.array(x_values[left_bound:right_bound])
1231
1758
  y_window = np.array(y_values[left_bound:right_bound])
1232
1759
 
1760
+ # Adjust degree based on window size
1761
+ adjusted_degree = degree if len(x_window) > 2 else 1 # Use linear fit if only two points are available
1762
+
1233
1763
  # Fit polynomial & evaluate at current point
1234
- poly_coeffs = np.polyfit(x_window, y_window, deg=degree)
1235
- smoothed_y.append(np.polyval(poly_coeffs, x_values[i]))
1764
+ poly_coeffs = np.polyfit(x_window, y_window, deg=adjusted_degree)
1765
+
1766
+ # Generate interpolated points between x_values[i] and x_values[i+1]
1767
+ x_interp = np.linspace(x_values[i], x_values[i+1], num_interpolated_points + 2) # Including endpoints
1768
+ y_interp = np.polyval(poly_coeffs, x_interp)
1769
+
1770
+ smoothed_x.extend(x_interp)
1771
+ smoothed_y.extend(y_interp)
1236
1772
 
1237
1773
  return smoothed_x, smoothed_y
1238
1774
 
1239
1775
 
1776
+
1777
+ ## Start of Section of Code for Styles and Converting between plotly and matplotlib Fig objectss ##
1778
+ # #There are a few things to know about the styles logic of JSONGrapher:
1779
+ # (1) There are actually two parts to the plot_style: a layout_style for the graph and a trace_styles_collection which will get applied to the individual dataseries.
1780
+ # So the plot_style is really supposed to be a dictionary with {"layout_style":"default", "trace_styles_collection":"default"} that way it is JSON compatible and avoids ambiguity.
1781
+ # A person can pass in dictionaries for layout_style and for trace_styles_collection and thereby create custom styles.
1782
+ # There are helper functions to extract style dictionaries once a person has a JSONGrapher record which they're happy with.
1783
+ # (2) We parse what the person provides as a style, so we accept things other than the ideal plot_style dictionary format.
1784
+ # If someone provides a single string, we'll use it for both layout_style and trace_styles_collection.
1785
+ # If we get a list of two, we'll expect that to be in the order of layout_style then trace_styles_collection
1786
+ # If we get a string that we can't find in the existing styles list, then we'll use the default.
1787
+ # (1) by default, exporting a JSONGRapher record to file will *not* include plot_styles. include_formatting will be an optional argument.
1788
+ # (2) There is an apply_plot_style function which will first put the style into self.fig_dict['plot_style'] so it stays there, before applying the style.
1789
+ # (3) For the plotting functions, they will have plot_style = {"layout_style":"", "trace_styles_collection":""} or = '' as their default argument value, which will result in checking if plot_style exists in the self.fig_dict already. If so, it will be used.
1790
+ # If somebody passes in a "None" type or the word none, then *no* style changes will be applied during plotting, relative to what the record already has.
1791
+ # One can pass a style in for the plotting functions. In those cases, we'll use the remove style option, then apply.
1792
+
1793
+ def parse_plot_style(plot_style):
1794
+ """
1795
+ Parse the given plot style and return a structured dictionary for layout and data series styles.
1796
+ If plot_style is missing a layout_style or trace_styles_collection then will set them as an empty string.
1797
+
1798
+ :param plot_style: None, str, list of two items, or a dictionary with at least one valid field.
1799
+ :return: dict with "layout_style" and "trace_styles_collection", ensuring defaults if missing.
1800
+ """
1801
+ if plot_style is None:
1802
+ parsed_plot_style = {"layout_style": None, "trace_styles_collection": None}
1803
+ elif isinstance(plot_style, str):
1804
+ parsed_plot_style = {"layout_style": plot_style, "trace_styles_collection": plot_style}
1805
+ elif isinstance(plot_style, list) and len(plot_style) == 2:
1806
+ parsed_plot_style = {"layout_style": plot_style[0], "trace_styles_collection": plot_style[1]}
1807
+ elif isinstance(plot_style, dict):
1808
+ if "trace_styles_collection" not in plot_style:
1809
+ if "trace_style_collection" in plot_style:
1810
+ print("Warning: plot_style has 'trace_style_collection', this key should be 'trace_styles_collection'. The key is being used, but the spelling error should be fixed.")
1811
+ plot_style["traces_styles_collection"] = plot_style["trace_style_collection"]
1812
+ elif "traces_style_collection" in plot_style:
1813
+ print("Warning: plot_style has 'traces_style_collection', this key should be 'trace_styles_collection'. The key is being used, but the spelling error should be fixed.")
1814
+ plot_style["traces_styles_collection"] = plot_style["traces_style_collection"]
1815
+ else:
1816
+ plot_style.setdefault("trace_styles_collection", '')
1817
+ if "layout_style" not in plot_style:
1818
+ plot_style.setdefault("layout_style", '')
1819
+ parsed_plot_style = {
1820
+ "layout_style": plot_style.get("layout_style", None),
1821
+ "trace_styles_collection": plot_style.get("trace_styles_collection", None),
1822
+ }
1823
+ else:
1824
+ raise ValueError("Invalid plot style: Must be None, a string, a list of two items, or a dictionary with valid fields.")
1825
+ return parsed_plot_style
1826
+
1827
+ #this function uses a stylename or list of stylename/dictionaries to apply *both* layout_style and trace_styles_collection
1828
+ #plot_style is a dictionary of form {"layout_style":"default", "trace_styles_collection":"default"}
1829
+ #However, the style_to_apply does not need to be passed in as a dictionary.
1830
+ #For example: style_to_apply = ['default', 'default'] or style_to_apply = 'science'.
1831
+ #IMPORTANT: This is the only function that will set a layout_style or trace_styles_collection that is an empty string into 'default'.
1832
+ # all other style applying functions (including parse_plot_style) will pass on the empty string or will do nothing if receiving an empty string.
1833
+ def apply_plot_style_to_plotly_dict(fig_dict, plot_style=None):
1834
+ if plot_style is None: # should not initialize mutable objects in arguments line, so doing here.
1835
+ plot_style = {"layout_style": {}, "trace_styles_collection": {}} # Fresh dictionary per function call
1836
+ #We first parse style_to_apply to get a properly formatted plot_style dictionary of form: {"layout_style":"default", "trace_styles_collection":"default"}
1837
+ plot_style = parse_plot_style(plot_style)
1838
+ plot_style.setdefault("layout_style",'') #fill with blank string if not present.
1839
+ plot_style.setdefault("trace_styles_collection",'') #fill with blank string if not present.
1840
+ #Code logic for layout style.
1841
+ if str(plot_style["layout_style"]).lower() != 'none': #take no action if received "None" or NoneType
1842
+ if plot_style["layout_style"] == '': #in this case, we're going to use the default.
1843
+ plot_style["layout_style"] = 'default'
1844
+ fig_dict = remove_layout_style_from_plotly_dict(fig_dict=fig_dict)
1845
+ fig_dict = apply_layout_style_to_plotly_dict(fig_dict=fig_dict, layout_style_to_apply=plot_style["layout_style"])
1846
+ #Code logic for trace_styles_collection style.
1847
+ if str(plot_style["trace_styles_collection"]).lower() != 'none': #take no action if received "None" or NoneType
1848
+ if plot_style["trace_styles_collection"] == '': #in this case, we're going to use the default.
1849
+ plot_style["trace_styles_collection"] = 'default'
1850
+ fig_dict = remove_trace_styles_collection_from_plotly_dict(fig_dict=fig_dict)
1851
+ fig_dict = apply_trace_styles_collection_to_plotly_dict(fig_dict=fig_dict,trace_styles_collection=plot_style["trace_styles_collection"])
1852
+ return fig_dict
1853
+
1854
+ def remove_plot_style_from_plotly_dict(fig_dict):
1855
+ """
1856
+ Remove both layout and data series styles from a Plotly figure dictionary.
1857
+
1858
+ :param fig_dict: dict, Plotly style fig_dict
1859
+ :return: dict, Updated Plotly style fig_dict with default formatting.
1860
+ """
1861
+ fig_dict = remove_layout_style_from_plotly_dict(fig_dict)
1862
+ fig_dict = remove_trace_styles_collection_from_plotly_dict(fig_dict)
1863
+ return fig_dict
1864
+
1865
+
1240
1866
  def convert_JSONGrapher_dict_to_matplotlib_fig(fig_dict):
1241
1867
  """
1242
1868
  Converts a Plotly figure dictionary into a Matplotlib figure without using pio.from_json.
@@ -1251,30 +1877,40 @@ def convert_JSONGrapher_dict_to_matplotlib_fig(fig_dict):
1251
1877
  fig, ax = plt.subplots()
1252
1878
 
1253
1879
  # Extract traces (data series)
1880
+ #This section is now deprecated. It has not been completely updated after the trace_style field was created.
1881
+ #There was old logic for plotly_trace_type which has been partially updated, but in fact the logic should be rewritten
1882
+ #to better accommodate the existence of both "trace_style" and "type". It may be that there should be
1883
+ #a helper function called
1254
1884
  for trace in fig_dict.get("data", []):
1255
- trace_type = trace.get("type", None)
1885
+ trace_style = trace.get("trace_style", '')
1886
+ plotly_trace_types = trace.get("type", '')
1887
+ if (plotly_trace_types == '') and (trace_style == ''):
1888
+ trace_style = 'scatter_spline'
1889
+ elif (plotly_trace_types == 'scatter') and (trace_style == ''):
1890
+ trace_style = 'scatter_spline'
1891
+ elif (trace_style == '') and (plotly_trace_types != ''):
1892
+ trace_style = plotly_trace_types
1256
1893
  # If type is missing, but mode indicates lines and shape is spline, assume it's a spline
1257
- if not trace_type and trace.get("mode") == "lines" and trace.get("line", {}).get("shape") == "spline":
1258
- trace_type = "spline"
1259
-
1894
+ if not trace_style and trace.get("mode") == "lines" and trace.get("line", {}).get("shape") == "spline":
1895
+ trace_style = "spline"
1260
1896
  x_values = trace.get("x", [])
1261
1897
  y_values = trace.get("y", [])
1262
1898
  trace_name = trace.get("name", "Data")
1263
- if trace_type == "bar":
1899
+ if trace_style == "bar":
1264
1900
  ax.bar(x_values, y_values, label=trace_name)
1265
-
1266
- elif trace_type == "scatter":
1901
+ elif trace_style == "scatter":
1902
+ mode = trace.get("mode", "")
1903
+ ax.scatter(x_values, y_values, label=trace_name, alpha=0.7)
1904
+ elif trace_style == "scatter_spline":
1267
1905
  mode = trace.get("mode", "")
1268
1906
  ax.scatter(x_values, y_values, label=trace_name, alpha=0.7)
1269
-
1270
1907
  # Attempt to simulate spline behavior if requested
1271
1908
  if "lines" in mode or trace.get("line", {}).get("shape") == "spline":
1272
1909
  print("Warning: Rolling polynomial approximation used instead of spline.")
1273
1910
  x_smooth, y_smooth = rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2)
1274
-
1275
1911
  # Add a label explicitly for the legend
1276
1912
  ax.plot(x_smooth, y_smooth, linestyle="-", label=f"{trace_name} Spline")
1277
- elif trace_type == "spline":
1913
+ elif trace_style == "spline":
1278
1914
  print("Warning: Using rolling polynomial approximation instead of true spline.")
1279
1915
  x_smooth, y_smooth = rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2)
1280
1916
  ax.plot(x_smooth, y_smooth, linestyle="-", label=f"{trace_name} Spline")
@@ -1307,9 +1943,6 @@ def convert_JSONGrapher_dict_to_matplotlib_fig(fig_dict):
1307
1943
  return fig
1308
1944
 
1309
1945
 
1310
-
1311
-
1312
-
1313
1946
  #The below function works, but because it depends on the python plotly package, we avoid using it
1314
1947
  #To decrease the number of dependencies.
1315
1948
  def convert_plotly_dict_to_matplotlib(fig_dict):
@@ -1327,7 +1960,7 @@ def convert_plotly_dict_to_matplotlib(fig_dict):
1327
1960
  matplotlib.figure.Figure: The corresponding Matplotlib figure.
1328
1961
  """
1329
1962
  import plotly.io as pio
1330
-
1963
+ import matplotlib.pyplot as plt
1331
1964
  # Convert JSON dictionary into a Plotly figure
1332
1965
  plotly_fig = pio.from_json(json.dumps(fig_dict))
1333
1966
 
@@ -1357,97 +1990,1045 @@ def convert_plotly_dict_to_matplotlib(fig_dict):
1357
1990
  ax.set_ylabel(plotly_fig.layout.yaxis.title.text if plotly_fig.layout.yaxis.title else "Y-Axis")
1358
1991
 
1359
1992
  return fig
1993
+
1994
+ def apply_trace_styles_collection_to_plotly_dict(fig_dict, trace_styles_collection="", trace_style_to_apply=""):
1995
+ """
1996
+ Iterates over all traces in the `data` list of a Plotly figure dictionary
1997
+ and applies styles to each one.
1998
+
1999
+ Args:
2000
+ fig_dict (dict): A dictionary containing a `data` field with Plotly traces.
2001
+ trace_style_to_apply (str): Optional style preset to apply. Default is "default".
2002
+
2003
+ Returns:
2004
+ dict: Updated Plotly figure dictionary with defaults applied to each trace.
2005
+
2006
+ """
2007
+ if type(trace_styles_collection) == type("string"):
2008
+ trace_styles_collection_name = trace_styles_collection
2009
+ else:
2010
+ trace_styles_collection_name = trace_styles_collection["name"]
2011
+
2012
+ if "data" in fig_dict and isinstance(fig_dict["data"], list):
2013
+ fig_dict["data"] = [apply_trace_style_to_single_data_series(data_series=trace,trace_styles_collection=trace_styles_collection, trace_style_to_apply=trace_style_to_apply) for trace in fig_dict["data"]]
1360
2014
 
2015
+ if "plot_style" not in fig_dict:
2016
+ fig_dict["plot_style"] = {}
2017
+ fig_dict["plot_style"]["trace_styles_collection"] = trace_styles_collection_name
2018
+ return fig_dict
2019
+
1361
2020
 
1362
- def apply_style_to_plotly_dict(plotly_json, style_name):
2021
+ # The logic in JSONGrapher is to apply the style information but to treat "type" differently
2022
+ # Accordingly, we use 'trace_styles_collection' as a field in JSONGrapher for each data_series.
2023
+ # compared to how plotly treats 'type' for a data series. So later in the process, when actually plotting with plotly, the 'type' field will get overwritten.
2024
+ def apply_trace_style_to_single_data_series(data_series, trace_styles_collection="", trace_style_to_apply=""):
1363
2025
  """
1364
- Apply a predefined style to a Plotly JSON object based on a style name which may be a journal name.
2026
+ Applies predefined styles to a single Plotly data series while preserving relevant fields.
2027
+
2028
+ Args:
2029
+ data_series (dict): A dictionary representing a single Plotly data series.
2030
+ trace_style_to_apply (str or dict): Name of the style preset or a custom style dictionary. Default is "default".
2031
+
2032
+ Returns:
2033
+ dict: Updated data series with style applied.
2034
+ """
2035
+ if not isinstance(data_series, dict):
2036
+ return data_series # Return unchanged if the data series is invalid.
2037
+ if isinstance(trace_style_to_apply, dict):#in this case, we'll set the data_series trace_style to match.
2038
+ data_series["trace_style"] = trace_style_to_apply
2039
+ if str(trace_style_to_apply) != str(''): #if we received a non-empty string (or dictionary), we'll put it into the data_series object.
2040
+ data_series["trace_style"] = trace_style_to_apply
2041
+ elif str(trace_style_to_apply) == str(''): #If we received an empty string for the trace_style_to apply (default JSONGrapher flow), we'll check in the data_series object.
2042
+ #first see if there is a trace_style in the data_series.
2043
+ trace_style = data_series.get("trace_style", "")
2044
+ #If it's "none", then we'll return the data series unchanged.
2045
+ #We consider it that for every trace_styles_collection, that "none" means to make no change.
2046
+ if str(trace_style).lower() == "none":
2047
+ return data_series
2048
+ #if we find a dictionary, we will set the trace_style_to_apply to that, to ensure we skip other string checks to use the dictionary.
2049
+ if isinstance(trace_style,dict):
2050
+ trace_style_to_apply = trace_style
2051
+ #if the trace_style_to_apply is a string and we have not received a trace_styles collection, then we have nothing
2052
+ #to use, so will return the data_series unchanged.
2053
+ if type(trace_style_to_apply) == type("string"):
2054
+ if (trace_styles_collection == '') or (str(trace_styles_collection).lower() == 'none'):
2055
+ return data_series
2056
+ #if the trace_style_to_apply is "none", we will return the series unchanged.
2057
+ if str(trace_style_to_apply).lower() == str("none"):
2058
+ return data_series
2059
+ #Add a couple of hardcoded cases.
2060
+ if type(trace_style_to_apply) == type("string"):
2061
+ if (trace_style_to_apply.lower() == "nature") or (trace_style_to_apply.lower() == "science"):
2062
+ trace_style_to_apply = "default"
2063
+
2064
+ #at this stage, should remove any existing formatting before applying new formatting.
2065
+ data_series = remove_trace_style_from_single_data_series(data_series)
2066
+
2067
+ # -------------------------------
2068
+ # Predefined trace_styles_collection
2069
+ # -------------------------------
2070
+ # Each trace_styles_collection is defined as a dictionary containing multiple trace_styles.
2071
+ # Users can select a style preset trace_styles_collection (e.g., "default", "minimalist", "bold"),
2072
+ # and this function will apply appropriate settings for the given trace_style.
2073
+ #
2074
+ # Examples of Supported trace_styles:
2075
+ # - "scatter_spline" (default when type is not specified)
2076
+ # - "scatter"
2077
+ # - "spline"
2078
+ # - "bar"
2079
+ # - "heatmap"
2080
+ #
2081
+ # Note: Colors are intentionally omitted to allow users to define their own.
2082
+ # However, predefined colorscales are applied for heatmaps.
2083
+
2084
+
2085
+ styles_available = JSONGrapher.styles.trace_styles_collection_library.styles_library
2086
+
2087
+ # Get the appropriate style dictionary
2088
+ if isinstance(trace_styles_collection, dict):
2089
+ styles_collection_dict = trace_styles_collection # Use custom style directly
2090
+ else:
2091
+ styles_collection_dict = styles_available.get(trace_styles_collection, {})
2092
+ if not styles_collection_dict: # Check if it's an empty dictionary
2093
+ print(f"Warning: trace_styles_collection named '{trace_styles_collection}' not found. Using 'default' trace_styles_collection instead.")
2094
+ styles_collection_dict = styles_available.get("default", {})
2095
+ # Determine the trace_style, defaulting to the first item in a given style if none is provided.
2096
+
2097
+ # Retrieve the specific style for the plot type
2098
+ if trace_style_to_apply == "":# if a trace_style_to_apply has not been supplied, we will get it from the dataseries.
2099
+ trace_style = data_series.get("trace_style", "")
2100
+ else:
2101
+ trace_style = trace_style_to_apply
2102
+
2103
+ if trace_style == "": #if the trace style is an empty string....
2104
+ trace_style = list(styles_collection_dict.keys())[0] #take the first trace_style name in the style_dict. In python 3.7 and later dictionary keys preserve ordering.
2105
+
2106
+ #If a person adds "__colorscale" to the end of a trace_style, like "scatter_spline__rainbow" we will extract the colorscale and apply it to the plot.
2107
+ #This should be done before extracting the trace_style from the styles_available, because we need to split the string to break out the trace_style
2108
+ colorscale = "" #initializing variable.
2109
+ if isinstance(trace_style, str): #check if it is a string type.
2110
+ if "__" in trace_style:
2111
+ trace_style, colorscale = trace_style.split("__")
2112
+
2113
+ colorscale_structure = "" #initialize this variable for use later. It tells us which fields to put the colorscale related values in. This should be done before regular trace_style fields are applied.
2114
+ #3D and bubble plots will have a colorscale by default.
2115
+ if trace_style == "bubble": #for bubble trace styles, we need to move the z values into the marker size. We also need to do this before the styles_dict collection is accessed, since then the trace_style becomes a dictionary.
2116
+ data_series = prepare_bubble_sizes(data_series)
2117
+ colorscale_structure = "bubble"
2118
+ elif trace_style == "mesh3d": #for bubble trace styles, we need to move the z values into the marker size. We also need to do this before the styles_dict collection is accessed, since then the trace_style becomes a dictionary.
2119
+ colorscale_structure = "mesh3d"
2120
+ elif trace_style == "scatter3d": #for bubble trace styles, we need to move the z values into the marker size. We also need to do this before the styles_dict collection is accessed, since then the trace_style becomes a dictionary.
2121
+ colorscale_structure = "scatter3d"
2122
+
2123
+ if trace_style in styles_collection_dict:
2124
+ trace_style = styles_collection_dict.get(trace_style)
2125
+ elif trace_style not in styles_collection_dict: # Check if it's an empty dictionary
2126
+ print(f"Warning: trace_style named '{trace_style}' not found in trace_styles_collection '{trace_styles_collection}'. Using the first trace_style in in trace_styles_collection '{trace_styles_collection}'.")
2127
+ trace_style = list(styles_collection_dict.keys())[0] #take the first trace_style name in the style_dict. In python 3.7 and later dictionary keys preserve ordering.
2128
+ trace_style = styles_collection_dict.get(trace_style)
2129
+
2130
+ # Apply type and other predefined settings
2131
+ data_series["type"] = trace_style.get("type")
2132
+ # Apply other attributes while preserving existing values
2133
+ for key, value in trace_style.items():
2134
+ if key not in ["type"]:
2135
+ if isinstance(value, dict): # Ensure value is a dictionary
2136
+ data_series.setdefault(key, {}).update(value)
2137
+ else:
2138
+ data_series[key] = value # Direct assignment for non-dictionary values
2139
+
2140
+ #Before applying colorscales, we check if we have recieved a colorscale from the user. If so, we'll need to parse the trace_type to assign the colorscale structure.
2141
+ if colorscale != "":
2142
+ #If it is a scatter plot with markers, then the colorscale_structure will be marker. Need to check for this before the lines alone case.
2143
+ if ("markers" in data_series["mode"]) or ("markers+lines" in data_series["mode"]) or ("lines+markers" in data_series["mode"]):
2144
+ colorscale_structure = "marker"
2145
+ elif ("lines" in data_series["mode"]):
2146
+ colorscale_structure = "line"
2147
+ elif ("bar" in data_series["type"]):
2148
+ colorscale_structure = "marker"
2149
+
2150
+ #Block of code to clean color values for 3D plots and 2D plots. It can't be just from the style dictionary because we need to point to data.
2151
+ def clean_color_values(list_of_values, variable_string_for_warning):
2152
+ if None in list_of_values:
2153
+ print("Warning: A colorscale based on" + variable_string_for_warning + "was requested. None values were found. They are being replaced with 0 values. It is recommended to provide data without None values.")
2154
+ color_values = [0 if value is None else value for value in list_of_values]
2155
+ else:
2156
+ color_values = list_of_values
2157
+ return color_values
2158
+
2159
+ if colorscale_structure == "bubble":
2160
+ #data_series["marker"]["colorscale"] = "viridis_r" #https://plotly.com/python/builtin-colorscales/
2161
+ data_series["marker"]["showscale"] = True
2162
+ if "z" in data_series:
2163
+ color_values = clean_color_values(list_of_values= data_series["z"], variable_string_for_warning="z")
2164
+ data_series["marker"]["color"] = color_values
2165
+ elif "z_points" in data_series:
2166
+ color_values = clean_color_values(list_of_values= data_series["z_points"], variable_string_for_warning="z_points")
2167
+ data_series["marker"]["color"] = color_values
2168
+ elif colorscale_structure == "scatter3d":
2169
+ #data_series["marker"]["colorscale"] = "viridis_r" #https://plotly.com/python/builtin-colorscales/
2170
+ data_series["marker"]["showscale"] = True
2171
+ if "z" in data_series:
2172
+ color_values = clean_color_values(list_of_values= data_series["z"], variable_string_for_warning="z")
2173
+ data_series["marker"]["color"] = color_values
2174
+ elif "z_points" in data_series:
2175
+ color_values = clean_color_values(list_of_values= data_series["z_points"], variable_string_for_warning="z_points")
2176
+ data_series["marker"]["color"] = color_values
2177
+ elif colorscale_structure == "mesh3d":
2178
+ #data_series["colorscale"] = "viridis_r" #https://plotly.com/python/builtin-colorscales/
2179
+ data_series["showscale"] = True
2180
+ if "z" in data_series:
2181
+ color_values = clean_color_values(list_of_values= data_series["z"], variable_string_for_warning="z")
2182
+ data_series["intensity"] = color_values
2183
+ elif "z_points" in data_series:
2184
+ color_values = clean_color_values(list_of_values= data_series["z_points"], variable_string_for_warning="z_points")
2185
+ data_series["intensity"] = color_values
2186
+ elif colorscale_structure == "marker":
2187
+ data_series["marker"]["colorscale"] = colorscale
2188
+ data_series["marker"]["showscale"] = True
2189
+ color_values = clean_color_values(list_of_values=data_series["y"], variable_string_for_warning="y")
2190
+ data_series["marker"]["color"] = color_values
2191
+ elif colorscale_structure == "line":
2192
+ data_series["line"]["colorscale"] = colorscale
2193
+ data_series["line"]["showscale"] = True
2194
+ color_values = clean_color_values(list_of_values=data_series["y"], variable_string_for_warning="y")
2195
+ data_series["line"]["color"] = color_values
2196
+
2197
+
2198
+ return data_series
2199
+
2200
+ def prepare_bubble_sizes(data_series):
2201
+ #To make a bubble plot with plotly, we are actually using a 2D plot
2202
+ #and are using the z values in a data_series to create the sizes of each point.
2203
+ #We also will scale them to some maximum bubble size that is specifed.
2204
+ if "marker" not in data_series:
2205
+ data_series["marker"] = {}
2206
+ if "z_points" in data_series:
2207
+ data_series["marker"]["size"] = data_series["z_points"]
2208
+ elif "z" in data_series:
2209
+ data_series["marker"]["size"] = data_series["z"]
2210
+
2211
+ #now need to normalize to the max value in the list.
2212
+ def normalize_to_max(starting_list):
2213
+ import numpy as np
2214
+ arr = np.array(starting_list) # Convert list to NumPy array for efficient operations
2215
+ max_value = np.max(arr) # Find the maximum value in the list
2216
+ if max_value == 0:
2217
+ normalized_values = np.zeros_like(arr) # If max_value is zero, return zeros
2218
+ else:
2219
+ normalized_values = arr / max_value # Otherwise, divide each element by max_value
2220
+ return normalized_values # Return the normalized values
2221
+ try:
2222
+ normalized_sizes = normalize_to_max(data_series["marker"]["size"])
2223
+ except KeyError as exc:
2224
+ raise KeyError("Error: During bubble plot bubble size normalization, there was an error. This usually means the z variable has not been populated. For example, by equation evaluation set to false or simulation evaluation set to false.")
2225
+
2226
+
2227
+ #Now biggest bubble is 1 (or 0) so multiply to enlarge to scale.
2228
+ if "max_bubble_size" in data_series:
2229
+ max_bubble_size = data_series["max_bubble_size"]
2230
+ else:
2231
+ max_bubble_size = 10
2232
+ scaled_sizes = normalized_sizes*max_bubble_size
2233
+ data_series["marker"]["size"] = scaled_sizes.tolist() #from numpy array back to list.
1365
2234
 
1366
- :param plotly_json: dict, Plotly JSON object.
1367
- :param style_name: str, Name of the style or journal.
1368
- :return: dict, Updated Plotly JSON object.
2235
+ #Now let's also set the text that appears during hovering to include the original data.
2236
+ if "z_points" in data_series:
2237
+ data_series["text"] = data_series["z_points"]
2238
+ elif "z" in data_series:
2239
+ data_series["text"] = data_series["z"]
2240
+
2241
+ return data_series
2242
+
2243
+
2244
+ #TODO: This logic should be changed in the future. There should be a separated function to remove formatting
2245
+ # versus just removing the current setting of "trace_styles_collection"
2246
+ # So the main class function will also be broken into two and/or need to take an optional argument in
2247
+ def remove_trace_styles_collection_from_plotly_dict(fig_dict):
1369
2248
  """
1370
- styles_available = {
1371
- "Nature": {
1372
- "layout": {
1373
- "title": {"font": {"size": 24, "family": "Times New Roman", "color": "black"}},
1374
- "font": {"size": 18, "family": "Times New Roman"},
1375
- "paper_bgcolor": "white",
1376
- "plot_bgcolor": "white",
1377
- }
1378
- },
1379
- "Science": {
1380
- "layout": {
1381
- "title": {"font": {"size": 22, "family": "Arial", "color": "black"}},
1382
- "font": {"size": 16, "family": "Arial"},
1383
- "paper_bgcolor": "white",
1384
- "plot_bgcolor": "white",
2249
+ Remove applied data series styles from a Plotly figure dictionary.
2250
+
2251
+ :param fig_dict: dict, Plotly style fig_dict
2252
+ :return: dict, Updated Plotly style fig_dict with default formatting.
2253
+ """
2254
+ #will remove formatting from the individual data_series, but will not remove formatting from any that have trace_style of "none".
2255
+ if isinstance(fig_dict, dict) and "data" in fig_dict and isinstance(fig_dict["data"], list):
2256
+ updated_data = [] # Initialize an empty list to store processed traces
2257
+ for trace in fig_dict["data"]:
2258
+ # Check if the trace has a "trace_style" field and if its value is "none" (case-insensitive)
2259
+ if trace.get("trace_style", "").lower() == "none":
2260
+ updated_data.append(trace) # Skip modification and keep the trace unchanged
2261
+ else:
2262
+ # Apply the function to modify the trace before adding it to the list
2263
+ updated_data.append(remove_trace_style_from_single_data_series(trace))
2264
+ # Update the "data" field with the processed traces
2265
+ fig_dict["data"] = updated_data
2266
+
2267
+
2268
+ #If being told to remove the style, should also pop it from fig_dict.
2269
+ if "plot_style" in fig_dict:
2270
+ if "trace_styles_collection" in fig_dict["plot_style"]:
2271
+ fig_dict["plot_style"].pop("trace_styles_collection")
2272
+ return fig_dict
2273
+
2274
+ def remove_trace_style_from_single_data_series(data_series):
2275
+ """
2276
+ Remove only formatting fields from a single Plotly data series while preserving all other fields.
2277
+
2278
+ Note: Since fig_dict data objects may contain custom fields (e.g., "equation", "metadata"),
2279
+ this function explicitly removes predefined **formatting** attributes while leaving all other data intact.
2280
+
2281
+ :param data_series: dict, A dictionary representing a single Plotly data series.
2282
+ :return: dict, Updated data series with formatting fields removed but key data retained.
2283
+ """
2284
+
2285
+ if not isinstance(data_series, dict):
2286
+ return data_series # Return unchanged if input is invalid.
2287
+
2288
+ # **Define formatting fields to remove**
2289
+ formatting_fields = {
2290
+ "mode", "line", "marker", "colorscale", "opacity", "fill", "fillcolor", "color", "intensity", "showscale",
2291
+ "legendgroup", "showlegend", "textposition", "textfont", "visible", "connectgaps", "cliponaxis", "showgrid"
2292
+ }
2293
+
2294
+ # **Create a new data series excluding only formatting fields**
2295
+ cleaned_data_series = {key: value for key, value in data_series.items() if key not in formatting_fields}
2296
+ #make the new data series into a JSONGrapherDataSeries object.
2297
+ new_data_series_object = JSONGrapherDataSeries()
2298
+ new_data_series_object.update_while_preserving_old_terms(cleaned_data_series)
2299
+ return new_data_series_object
2300
+
2301
+ def extract_trace_style_by_index(fig_dict, data_series_index, new_trace_style_name='', extract_colors=False):
2302
+ data_series_dict = fig_dict["data"][data_series_index]
2303
+ extracted_trace_style = extract_trace_style_from_data_series_dict(data_series_dict=data_series_dict, new_trace_style_name=new_trace_style_name, extract_colors=extract_colors)
2304
+ return extracted_trace_style
2305
+
2306
+ def extract_trace_style_from_data_series_dict(data_series_dict, new_trace_style_name='', additional_attributes_to_extract=None, extract_colors=False):
2307
+ """
2308
+ Extract formatting attributes from a given Plotly data series.
2309
+
2310
+ The function scans the provided `data_series` dictionary and returns a new dictionary
2311
+ containing only the predefined formatting fields.
2312
+
2313
+ Examples of formatting attributes extracted:
2314
+ - "type"
2315
+ - "mode"
2316
+ - "line"
2317
+ - "marker"
2318
+ - "colorscale"
2319
+ - "opacity"
2320
+ - "fill"
2321
+ - "legendgroup"
2322
+ - "showlegend"
2323
+ - "textposition"
2324
+ - "textfont"
2325
+
2326
+ :param data_series_dict: dict, A dictionary representing a single Plotly data series.
2327
+ :param trace_style: string, the key name for what user wants to call the trace_style in the style, after extraction.
2328
+ :return: dict, A dictionary containing only the formatting attributes.
2329
+ """
2330
+ if additional_attributes_to_extract is None: #in python, it's not good to make an empty list a default argument.
2331
+ additional_attributes_to_extract = []
2332
+
2333
+ if new_trace_style_name=='':
2334
+ new_trace_style_name = data_series_dict.get("trace_style", "") #keep blank if not present.
2335
+ if new_trace_style_name=='':
2336
+ new_trace_style_name = "custom"
2337
+
2338
+ if not isinstance(data_series_dict, dict):
2339
+ return {} # Return an empty dictionary if input is invalid.
2340
+
2341
+ # Define known formatting attributes. This is a set (not a dictionary, not a list)
2342
+ formatting_fields = {
2343
+ "type", "mode", "line", "marker", "colorscale", "opacity", "fill", "fillcolor", "color", "intensity", "showscale",
2344
+ "legendgroup", "showlegend", "textposition", "textfont", "visible", "connectgaps", "cliponaxis", "showgrid"
2345
+ }
2346
+
2347
+ formatting_fields.update(additional_attributes_to_extract)
2348
+ # Extract only formatting-related attributes
2349
+ trace_style_dict = {key: value for key, value in data_series_dict.items() if key in formatting_fields}
2350
+
2351
+ #Pop out colors if we are not extracting them.
2352
+ if extract_colors == False:
2353
+ if "marker" in trace_style_dict:
2354
+ if "color" in trace_style_dict["marker"]:
2355
+ trace_style_dict["marker"].pop("color")
2356
+ if "line" in trace_style_dict:
2357
+ if "color" in trace_style_dict["line"]:
2358
+ trace_style_dict["line"].pop("color")
2359
+ if "colorscale" in trace_style_dict: # Handles top-level colorscale for heatmaps, choropleths
2360
+ trace_style_dict.pop("colorscale")
2361
+ if "fillcolor" in trace_style_dict: # Handles fill colors
2362
+ trace_style_dict.pop("fillcolor")
2363
+ if "textfont" in trace_style_dict:
2364
+ if "color" in trace_style_dict["textfont"]: # Handles text color
2365
+ trace_style_dict["textfont"].pop("color")
2366
+ if "legendgrouptitle" in trace_style_dict and isinstance(trace_style_dict["legendgrouptitle"], dict):
2367
+ if "font" in trace_style_dict["legendgrouptitle"] and isinstance(trace_style_dict["legendgrouptitle"]["font"], dict):
2368
+ if "color" in trace_style_dict["legendgrouptitle"]["font"]:
2369
+ trace_style_dict["legendgrouptitle"]["font"].pop("color")
2370
+ extracted_trace_style = {new_trace_style_name : trace_style_dict} #this is a trace_style dict.
2371
+ return extracted_trace_style #this is a trace_style dict.
2372
+
2373
+ #export a single trace_style dictionary to .json.
2374
+ def write_trace_style_to_file(trace_style_dict, trace_style_name, filename):
2375
+ # Ensure the filename ends with .json
2376
+ if not filename.lower().endswith(".json"):
2377
+ filename += ".json"
2378
+
2379
+ json_structure = {
2380
+ "trace_style": {
2381
+ "name": trace_style_name,
2382
+ trace_style_name: {
2383
+ trace_style_dict
1385
2384
  }
1386
2385
  }
1387
2386
  }
1388
2387
 
1389
- # Get the style for the specified journal, default to no change if not found
1390
- style_dict = styles_available.get(style_name, {})
1391
-
1392
- # Ensure title field is merged properly to avoid overwriting
1393
- plotly_json.setdefault("layout", {})
1394
- plotly_json["layout"].setdefault("title", {})
1395
-
1396
- # Merge title settings separately to preserve existing text
1397
- plotly_json["layout"]["title"] = {**plotly_json["layout"]["title"], **style_dict.get("layout", {}).get("title", {})}
2388
+ with open(filename, "w", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
2389
+ json.dump(json_structure, file, indent=4)
2390
+
2391
+
2392
+ #export an entire trace_styles_collection to .json. The trace_styles_collection is dict.
2393
+ def write_trace_styles_collection_to_file(trace_styles_collection, trace_styles_collection_name, filename):
2394
+ if "trace_styles_collection" in trace_styles_collection: #We may receive a traces_style collection in a container. If so, we pull the traces_style_collection out.
2395
+ trace_styles_collection = trace_styles_collection[trace_styles_collection["name"]]
2396
+ # Ensure the filename ends with .json
2397
+ if not filename.lower().endswith(".json"):
2398
+ filename += ".json"
2399
+
2400
+ json_structure = {
2401
+ "trace_styles_collection": {
2402
+ "name": trace_styles_collection_name,
2403
+ trace_styles_collection_name: trace_styles_collection
2404
+ }
2405
+ }
2406
+
2407
+ with open(filename, "w", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
2408
+ json.dump(json_structure, file, indent=4)
2409
+
2410
+
2411
+
2412
+ #export an entire trace_styles_collection from .json. THe trace_styles_collection is dict.
2413
+ def import_trace_styles_collection(filename):
2414
+ # Ensure the filename ends with .json
2415
+ if not filename.lower().endswith(".json"):
2416
+ filename += ".json"
2417
+
2418
+ with open(filename, "r", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
2419
+ data = json.load(file)
2420
+
2421
+ # Validate JSON structure
2422
+ containing_dict = data.get("trace_styles_collection")
2423
+ if not isinstance(containing_dict, dict):
2424
+ raise ValueError("Error: Missing or malformed 'trace_styles_collection'.")
2425
+
2426
+ collection_name = containing_dict.get("name")
2427
+ if not isinstance(collection_name, str) or collection_name not in containing_dict:
2428
+ raise ValueError(f"Error: Expected dictionary '{collection_name}' is missing or malformed.")
2429
+ trace_styles_collection = containing_dict[collection_name]
2430
+ # Return only the dictionary corresponding to the collection name
2431
+ return trace_styles_collection
2432
+
2433
+
2434
+ #export an entire trace_styles_collection from .json. THe trace_styles_collection is dict.
2435
+ def import_trace_style(filename):
2436
+ # Ensure the filename ends with .json
2437
+ if not filename.lower().endswith(".json"):
2438
+ filename += ".json"
2439
+
2440
+ with open(filename, "r", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
2441
+ data = json.load(file)
2442
+
2443
+ # Validate JSON structure
2444
+ containing_dict = data.get("trace_style")
2445
+ if not isinstance(containing_dict, dict):
2446
+ raise ValueError("Error: Missing or malformed 'trace_style'.")
2447
+
2448
+ style_name = containing_dict.get("name")
2449
+ if not isinstance(style_name, str) or style_name not in containing_dict:
2450
+ raise ValueError(f"Error: Expected dictionary '{style_name}' is missing or malformed.")
2451
+ trace_style_dict = containing_dict[style_name]
2452
+
2453
+ # Return only the dictionary corresponding to the trace style name
2454
+ return trace_style_dict
2455
+
2456
+
2457
+ def apply_layout_style_to_plotly_dict(fig_dict, layout_style_to_apply="default"):
2458
+ """
2459
+ Apply a predefined style to a Plotly fig_dict while preserving non-cosmetic fields.
1398
2460
 
1399
- # Merge other layout settings
1400
- for key, value in style_dict.get("layout", {}).items():
1401
- if key != "title": # Skip title since it was handled separately
1402
- plotly_json["layout"][key] = value
2461
+ :param fig_dict: dict, Plotly style fig_dict
2462
+ :param layout_style_to_apply: str, Name of the style or journal, or a style dictionary to apply.
2463
+ :return: dict, Updated Plotly style fig_dict.
2464
+ """
2465
+ if type(layout_style_to_apply) == type("string"):
2466
+ layout_style_to_apply_name = layout_style_to_apply
2467
+ else:
2468
+ layout_style_to_apply_name = list(layout_style_to_apply.keys())[0]#if it is a dictionary, it will have one key which is its name.
2469
+ if (layout_style_to_apply == '') or (str(layout_style_to_apply).lower() == 'none'):
2470
+ return fig_dict
2471
+
2472
+ #Hardcoding some cases as ones that will call the default layout, for convenience.
2473
+ if (layout_style_to_apply.lower() == "minimalist") or (layout_style_to_apply.lower() == "bold"):
2474
+ layout_style_to_apply = "default"
2475
+
2476
+
2477
+ styles_available = JSONGrapher.styles.layout_styles_library.styles_library
2478
+
2479
+
2480
+ # Use or get the style specified, or use default if not found
2481
+ if isinstance(layout_style_to_apply, dict):
2482
+ style_dict = layout_style_to_apply
2483
+ else:
2484
+ style_dict = styles_available.get(layout_style_to_apply, {})
2485
+ if not style_dict: # Check if it's an empty dictionary
2486
+ print(f"Style named '{layout_style_to_apply}' not found with explicit layout dictionary. Using 'default' layout style.")
2487
+ style_dict = styles_available.get("default", {})
2488
+
2489
+ # Ensure layout exists in the figure
2490
+ fig_dict.setdefault("layout", {})
2491
+
2492
+ # **Extract non-cosmetic fields**
2493
+ non_cosmetic_fields = {
2494
+ "title.text": fig_dict.get("layout", {}).get("title", {}).get("text", None),
2495
+ "xaxis.title.text": fig_dict.get("layout", {}).get("xaxis", {}).get("title", {}).get("text", None),
2496
+ "yaxis.title.text": fig_dict.get("layout", {}).get("yaxis", {}).get("title", {}).get("text", None),
2497
+ "zaxis.title.text": fig_dict.get("layout", {}).get("zaxis", {}).get("title", {}).get("text", None),
2498
+ "legend.title.text": fig_dict.get("layout", {}).get("legend", {}).get("title", {}).get("text", None),
2499
+ "annotations.text": [
2500
+ annotation.get("text", None) for annotation in fig_dict.get("layout", {}).get("annotations", [])
2501
+ ],
2502
+ "updatemenus.buttons.label": [
2503
+ button.get("label", None) for menu in fig_dict.get("layout", {}).get("updatemenus", [])
2504
+ for button in menu.get("buttons", [])
2505
+ ],
2506
+ "coloraxis.colorbar.title.text": fig_dict.get("layout", {}).get("coloraxis", {}).get("colorbar", {}).get("title", {}).get("text", None),
2507
+ }
2508
+
2509
+ # **Apply style dictionary to create a fresh layout object**
2510
+ new_layout = style_dict.get("layout", {}).copy()
2511
+
2512
+ # **Restore non-cosmetic fields**
2513
+ if non_cosmetic_fields["title.text"]:
2514
+ new_layout.setdefault("title", {})["text"] = non_cosmetic_fields["title.text"]
2515
+
2516
+ if non_cosmetic_fields["xaxis.title.text"]:
2517
+ new_layout.setdefault("xaxis", {}).setdefault("title", {})["text"] = non_cosmetic_fields["xaxis.title.text"]
2518
+
2519
+ if non_cosmetic_fields["yaxis.title.text"]:
2520
+ new_layout.setdefault("yaxis", {}).setdefault("title", {})["text"] = non_cosmetic_fields["yaxis.title.text"]
2521
+
2522
+ if non_cosmetic_fields["zaxis.title.text"]:
2523
+ new_layout.setdefault("zaxis", {}).setdefault("title", {})["text"] = non_cosmetic_fields["zaxis.title.text"]
2524
+
2525
+ if non_cosmetic_fields["legend.title.text"]:
2526
+ new_layout.setdefault("legend", {}).setdefault("title", {})["text"] = non_cosmetic_fields["legend.title.text"]
2527
+
2528
+ if non_cosmetic_fields["annotations.text"]:
2529
+ new_layout["annotations"] = [{"text": text} for text in non_cosmetic_fields["annotations.text"]]
2530
+
2531
+ if non_cosmetic_fields["updatemenus.buttons.label"]:
2532
+ new_layout["updatemenus"] = [{"buttons": [{"label": label} for label in non_cosmetic_fields["updatemenus.buttons.label"]]}]
2533
+
2534
+ if non_cosmetic_fields["coloraxis.colorbar.title.text"]:
2535
+ new_layout.setdefault("coloraxis", {}).setdefault("colorbar", {})["title"] = {"text": non_cosmetic_fields["coloraxis.colorbar.title.text"]}
2536
+
2537
+ # **Assign the new layout back into the figure dictionary**
2538
+ fig_dict["layout"] = new_layout
2539
+ #Now update the fig_dict to signify the new layout_style used.
2540
+ if "plot_style" not in fig_dict:
2541
+ fig_dict["plot_style"] = {}
2542
+ fig_dict["plot_style"]["layout_style"] = layout_style_to_apply_name
2543
+ return fig_dict
2544
+
2545
+ #TODO: This logic should be changed in the future. There should be a separated function to remove formatting
2546
+ # versus just removing the current setting of "layout_style"
2547
+ # So the main class function will also be broken into two and/or need to take an optional argument in
2548
+ def remove_layout_style_from_plotly_dict(fig_dict):
2549
+ """
2550
+ Remove applied layout styles from a Plotly figure dictionary while preserving essential content.
2551
+
2552
+ :param fig_dict: dict, Plotly style fig_dict
2553
+ :return: dict, Updated Plotly style fig_dict with styles removed but key data intact.
2554
+ """
2555
+ if "layout" in fig_dict:
2556
+ style_keys = ["font", "paper_bgcolor", "plot_bgcolor", "gridcolor", "gridwidth", "tickfont", "linewidth"]
2557
+
2558
+ # **Store non-cosmetic fields if present, otherwise assign None**
2559
+ non_cosmetic_fields = {
2560
+ "title.text": fig_dict.get("layout", {}).get("title", {}).get("text", None),
2561
+ "xaxis.title.text": fig_dict.get("layout", {}).get("xaxis", {}).get("title", {}).get("text", None),
2562
+ "yaxis.title.text": fig_dict.get("layout", {}).get("yaxis", {}).get("title", {}).get("text", None),
2563
+ "zaxis.title.text": fig_dict.get("layout", {}).get("zaxis", {}).get("title", {}).get("text", None),
2564
+ "legend.title.text": fig_dict.get("layout", {}).get("legend", {}).get("title", {}).get("text", None),
2565
+ "annotations.text": [annotation.get("text", None) for annotation in fig_dict.get("layout", {}).get("annotations", [])],
2566
+ "updatemenus.buttons.label": [
2567
+ button.get("label", None) for menu in fig_dict.get("layout", {}).get("updatemenus", [])
2568
+ for button in menu.get("buttons", [])
2569
+ ],
2570
+ "coloraxis.colorbar.title.text": fig_dict.get("layout", {}).get("coloraxis", {}).get("colorbar", {}).get("title", {}).get("text", None),
2571
+ }
2572
+
2573
+ # Preserve title text while removing font styling
2574
+ if "title" in fig_dict["layout"] and isinstance(fig_dict["layout"]["title"], dict):
2575
+ fig_dict["layout"]["title"] = {"text": non_cosmetic_fields["title.text"]} if non_cosmetic_fields["title.text"] is not None else {}
2576
+
2577
+ # Preserve axis titles while stripping font styles
2578
+ for axis in ["xaxis", "yaxis", "zaxis"]:
2579
+ if axis in fig_dict["layout"] and isinstance(fig_dict["layout"][axis], dict):
2580
+ if "title" in fig_dict["layout"][axis] and isinstance(fig_dict["layout"][axis]["title"], dict):
2581
+ fig_dict["layout"][axis]["title"] = {"text": non_cosmetic_fields[f"{axis}.title.text"]} if non_cosmetic_fields[f"{axis}.title.text"] is not None else {}
2582
+
2583
+ # Remove style-related attributes but keep axis configurations
2584
+ for key in style_keys:
2585
+ fig_dict["layout"][axis].pop(key, None)
2586
+
2587
+ # Preserve legend title text while stripping font styling
2588
+ if "legend" in fig_dict["layout"] and isinstance(fig_dict["layout"]["legend"], dict):
2589
+ if "title" in fig_dict["layout"]["legend"] and isinstance(fig_dict["layout"]["legend"]["title"], dict):
2590
+ fig_dict["layout"]["legend"]["title"] = {"text": non_cosmetic_fields["legend.title.text"]} if non_cosmetic_fields["legend.title.text"] is not None else {}
2591
+ fig_dict["layout"]["legend"].pop("font", None)
2592
+
2593
+ # Preserve annotations text while stripping style attributes
2594
+ if "annotations" in fig_dict["layout"]:
2595
+ fig_dict["layout"]["annotations"] = [
2596
+ {"text": text} if text is not None else {} for text in non_cosmetic_fields["annotations.text"]
2597
+ ]
2598
+
2599
+ # Preserve update menu labels while stripping styles
2600
+ if "updatemenus" in fig_dict["layout"]:
2601
+ for menu in fig_dict["layout"]["updatemenus"]:
2602
+ for i, button in enumerate(menu.get("buttons", [])):
2603
+ button.clear()
2604
+ if non_cosmetic_fields["updatemenus.buttons.label"][i] is not None:
2605
+ button["label"] = non_cosmetic_fields["updatemenus.buttons.label"][i]
2606
+
2607
+ # Preserve color bar title while stripping styles
2608
+ if "coloraxis" in fig_dict["layout"] and "colorbar" in fig_dict["layout"]["coloraxis"]:
2609
+ fig_dict["layout"]["coloraxis"]["colorbar"]["title"] = {"text": non_cosmetic_fields["coloraxis.colorbar.title.text"]} if non_cosmetic_fields["coloraxis.colorbar.title.text"] is not None else {}
2610
+
2611
+ # Remove general style settings without clearing layout structure
2612
+ for key in style_keys:
2613
+ fig_dict["layout"].pop(key, None)
2614
+
2615
+ #If being told to remove the style, should also pop it from fig_dict.
2616
+ if "plot_style" in fig_dict:
2617
+ if "layout_style" in fig_dict["plot_style"]:
2618
+ fig_dict["plot_style"].pop("layout_style")
2619
+ return fig_dict
2620
+
2621
+ def extract_layout_style_from_plotly_dict(fig_dict):
2622
+ """
2623
+ Extract a layout style dictionary from a given Plotly JSON object, including background color, grids, and other appearance attributes.
2624
+
2625
+ :param fig_dict: dict, Plotly JSON object.
2626
+ :return: dict, Extracted style settings.
2627
+ """
2628
+
2629
+
2630
+ # **Extraction Phase** - Collect cosmetic fields if they exist
2631
+ layout = fig_dict.get("layout", {})
2632
+
2633
+ # Note: Each assignment below will return None if the corresponding field is missing
2634
+ title_font = layout.get("title", {}).get("font")
2635
+ title_x = layout.get("title", {}).get("x")
2636
+ title_y = layout.get("title", {}).get("y")
2637
+
2638
+ global_font = layout.get("font")
2639
+ paper_bgcolor = layout.get("paper_bgcolor")
2640
+ plot_bgcolor = layout.get("plot_bgcolor")
2641
+ margin = layout.get("margin")
2642
+
2643
+ # Extract x-axis cosmetic fields
2644
+ xaxis_title_font = layout.get("xaxis", {}).get("title", {}).get("font")
2645
+ xaxis_tickfont = layout.get("xaxis", {}).get("tickfont")
2646
+ xaxis_gridcolor = layout.get("xaxis", {}).get("gridcolor")
2647
+ xaxis_gridwidth = layout.get("xaxis", {}).get("gridwidth")
2648
+ xaxis_zerolinecolor = layout.get("xaxis", {}).get("zerolinecolor")
2649
+ xaxis_zerolinewidth = layout.get("xaxis", {}).get("zerolinewidth")
2650
+ xaxis_tickangle = layout.get("xaxis", {}).get("tickangle")
2651
+
2652
+ # **Set flag for x-axis extraction**
2653
+ xaxis = any([
2654
+ xaxis_title_font, xaxis_tickfont, xaxis_gridcolor, xaxis_gridwidth,
2655
+ xaxis_zerolinecolor, xaxis_zerolinewidth, xaxis_tickangle
2656
+ ])
2657
+
2658
+ # Extract y-axis cosmetic fields
2659
+ yaxis_title_font = layout.get("yaxis", {}).get("title", {}).get("font")
2660
+ yaxis_tickfont = layout.get("yaxis", {}).get("tickfont")
2661
+ yaxis_gridcolor = layout.get("yaxis", {}).get("gridcolor")
2662
+ yaxis_gridwidth = layout.get("yaxis", {}).get("gridwidth")
2663
+ yaxis_zerolinecolor = layout.get("yaxis", {}).get("zerolinecolor")
2664
+ yaxis_zerolinewidth = layout.get("yaxis", {}).get("zerolinewidth")
2665
+ yaxis_tickangle = layout.get("yaxis", {}).get("tickangle")
2666
+
2667
+ # **Set flag for y-axis extraction**
2668
+ yaxis = any([
2669
+ yaxis_title_font, yaxis_tickfont, yaxis_gridcolor, yaxis_gridwidth,
2670
+ yaxis_zerolinecolor, yaxis_zerolinewidth, yaxis_tickangle
2671
+ ])
2672
+
2673
+ # Extract legend styling
2674
+ legend_font = layout.get("legend", {}).get("font")
2675
+ legend_x = layout.get("legend", {}).get("x")
2676
+ legend_y = layout.get("legend", {}).get("y")
2677
+
2678
+ # **Assignment Phase** - Reconstruct dictionary in a structured manner
2679
+ extracted_layout_style = {"layout": {}}
2680
+
2681
+ if title_font or title_x:
2682
+ extracted_layout_style["layout"]["title"] = {}
2683
+ if title_font:
2684
+ extracted_layout_style["layout"]["title"]["font"] = title_font
2685
+ if title_x:
2686
+ extracted_layout_style["layout"]["title"]["x"] = title_x
2687
+ if title_y:
2688
+ extracted_layout_style["layout"]["title"]["y"] = title_y
2689
+
2690
+ if global_font:
2691
+ extracted_layout_style["layout"]["font"] = global_font
2692
+
2693
+ if paper_bgcolor:
2694
+ extracted_layout_style["layout"]["paper_bgcolor"] = paper_bgcolor
2695
+ if plot_bgcolor:
2696
+ extracted_layout_style["layout"]["plot_bgcolor"] = plot_bgcolor
2697
+ if margin:
2698
+ extracted_layout_style["layout"]["margin"] = margin
2699
+
2700
+ if xaxis:
2701
+ extracted_layout_style["layout"]["xaxis"] = {}
2702
+ if xaxis_title_font:
2703
+ extracted_layout_style["layout"]["xaxis"]["title"] = {"font": xaxis_title_font}
2704
+ if xaxis_tickfont:
2705
+ extracted_layout_style["layout"]["xaxis"]["tickfont"] = xaxis_tickfont
2706
+ if xaxis_gridcolor:
2707
+ extracted_layout_style["layout"]["xaxis"]["gridcolor"] = xaxis_gridcolor
2708
+ if xaxis_gridwidth:
2709
+ extracted_layout_style["layout"]["xaxis"]["gridwidth"] = xaxis_gridwidth
2710
+ if xaxis_zerolinecolor:
2711
+ extracted_layout_style["layout"]["xaxis"]["zerolinecolor"] = xaxis_zerolinecolor
2712
+ if xaxis_zerolinewidth:
2713
+ extracted_layout_style["layout"]["xaxis"]["zerolinewidth"] = xaxis_zerolinewidth
2714
+ if xaxis_tickangle:
2715
+ extracted_layout_style["layout"]["xaxis"]["tickangle"] = xaxis_tickangle
2716
+
2717
+ if yaxis:
2718
+ extracted_layout_style["layout"]["yaxis"] = {}
2719
+ if yaxis_title_font:
2720
+ extracted_layout_style["layout"]["yaxis"]["title"] = {"font": yaxis_title_font}
2721
+ if yaxis_tickfont:
2722
+ extracted_layout_style["layout"]["yaxis"]["tickfont"] = yaxis_tickfont
2723
+ if yaxis_gridcolor:
2724
+ extracted_layout_style["layout"]["yaxis"]["gridcolor"] = yaxis_gridcolor
2725
+ if yaxis_gridwidth:
2726
+ extracted_layout_style["layout"]["yaxis"]["gridwidth"] = yaxis_gridwidth
2727
+ if yaxis_zerolinecolor:
2728
+ extracted_layout_style["layout"]["yaxis"]["zerolinecolor"] = yaxis_zerolinecolor
2729
+ if yaxis_zerolinewidth:
2730
+ extracted_layout_style["layout"]["yaxis"]["zerolinewidth"] = yaxis_zerolinewidth
2731
+ if yaxis_tickangle:
2732
+ extracted_layout_style["layout"]["yaxis"]["tickangle"] = yaxis_tickangle
2733
+
2734
+ if legend_font or legend_x or legend_y:
2735
+ extracted_layout_style["layout"]["legend"] = {}
2736
+ if legend_font:
2737
+ extracted_layout_style["layout"]["legend"]["font"] = legend_font
2738
+ if legend_x:
2739
+ extracted_layout_style["layout"]["legend"]["x"] = legend_x
2740
+ if legend_y:
2741
+ extracted_layout_style["layout"]["legend"]["y"] = legend_y
2742
+
2743
+ return extracted_layout_style
2744
+
2745
+ ## Start of Section of Code for Styles and Converting between plotly and matplotlib Fig objectss ##
2746
+
2747
+ ### Start of section of code with functions for extracting and updating x and y ranges of data series ###
2748
+
2749
+ def update_implicit_data_series_x_ranges(fig_dict, range_dict):
2750
+ """
2751
+ Updates the x_range_default values for all simulate and equation data series
2752
+ in a given figure dictionary using the provided range dictionary.
2753
+
2754
+ Args:
2755
+ fig_dict (dict): The original figure dictionary containing various data series.
2756
+ range_dict (dict): A dictionary with keys "min_x" and "max_x" providing the
2757
+ global minimum and maximum x values for updates.
2758
+
2759
+ Returns:
2760
+ dict: A new figure dictionary with updated x_range_default values for
2761
+ equation and simulate series, while keeping other data unchanged.
1403
2762
 
1404
- return plotly_json
2763
+ Notes:
2764
+ - If min_x or max_x in range_dict is None, the function preserves the
2765
+ existing x_range_default values instead of overwriting them.
2766
+ - Uses deepcopy to ensure modifications do not affect the original fig_dict.
2767
+ """
2768
+ import copy # Import inside function to limit scope
2769
+
2770
+ updated_fig_dict = copy.deepcopy(fig_dict) # Deep copy avoids modifying original data
2771
+
2772
+ min_x = range_dict["min_x"]
2773
+ max_x = range_dict["max_x"]
2774
+
2775
+ for data_series in updated_fig_dict.get("data", []):
2776
+ if "equation" in data_series:
2777
+ equation_info = data_series["equation"]
2778
+
2779
+ # Determine valid values before assignment
2780
+ min_x_value = min_x if (min_x is not None) else equation_info.get("x_range_default", [None, None])[0]
2781
+ max_x_value = max_x if (max_x is not None) else equation_info.get("x_range_default", [None, None])[1]
2782
+
2783
+ # Assign updated values
2784
+ equation_info["x_range_default"] = [min_x_value, max_x_value]
2785
+
2786
+ elif "simulate" in data_series:
2787
+ simulate_info = data_series["simulate"]
2788
+
2789
+ # Determine valid values before assignment
2790
+ min_x_value = min_x if (min_x is not None) else simulate_info.get("x_range_default", [None, None])[0]
2791
+ max_x_value = max_x if (max_x is not None) else simulate_info.get("x_range_default", [None, None])[1]
2792
+
2793
+ # Assign updated values
2794
+ simulate_info["x_range_default"] = [min_x_value, max_x_value]
2795
+
2796
+ return updated_fig_dict
2797
+
2798
+
2799
+
2800
+
2801
+ def get_fig_dict_ranges(fig_dict, skip_equations=False, skip_simulations=False):
2802
+ """
2803
+ Extracts minimum and maximum x/y values from each data_series in a fig_dict, as well as overall min and max for x and y.
2804
+
2805
+ Args:
2806
+ fig_dict (dict): The figure dictionary containing multiple data series.
2807
+ skip_equations (bool): If True, equation-based data series are ignored.
2808
+ skip_simulations (bool): If True, simulation-based data series are ignored.
2809
+
2810
+ Returns:
2811
+ tuple:
2812
+ - fig_dict_ranges (dict): A dictionary containing overall min/max x/y values across all valid series.
2813
+ - data_series_ranges (dict): A dictionary with individual min/max values for each data series.
2814
+
2815
+ Notes:
2816
+ - Equations and simulations have predefined x-range defaults and limits.
2817
+ - If their x-range is absent, individual data series values are used.
2818
+ - Ensures empty lists don't trigger errors when computing min/max values.
2819
+ """
2820
+ # Initialize final range values to None to ensure assignment
2821
+ fig_dict_ranges = {
2822
+ "min_x": None,
2823
+ "max_x": None,
2824
+ "min_y": None,
2825
+ "max_y": None
2826
+ }
2827
+
2828
+ data_series_ranges = {
2829
+ "min_x": [],
2830
+ "max_x": [],
2831
+ "min_y": [],
2832
+ "max_y": []
2833
+ }
2834
+
2835
+ for data_series in fig_dict.get("data", []):
2836
+ min_x, max_x, min_y, max_y = None, None, None, None # Initialize extrema as None
2837
+
2838
+ # Determine if the data series contains either "equation" or "simulate"
2839
+ if "equation" in data_series:
2840
+ if skip_equations:
2841
+ implicit_data_series_to_extract_from = None
2842
+ # Will Skip processing, but still append None values
2843
+ else:
2844
+ implicit_data_series_to_extract_from = data_series["equation"]
2845
+
2846
+ elif "simulate" in data_series:
2847
+ if skip_simulations:
2848
+ implicit_data_series_to_extract_from = None
2849
+ # Will Skip processing, but still append None values
2850
+ else:
2851
+ implicit_data_series_to_extract_from = data_series["simulate"]
2852
+
2853
+ else:
2854
+ implicit_data_series_to_extract_from = None # No equation or simulation, process x and y normally
2855
+
2856
+ if implicit_data_series_to_extract_from:
2857
+ x_range_default = implicit_data_series_to_extract_from.get("x_range_default", [None, None])
2858
+ x_range_limits = implicit_data_series_to_extract_from.get("x_range_limits", [None, None])
2859
+
2860
+ # Assign values, but keep None if missing
2861
+ min_x = (x_range_default[0] if (x_range_default[0] is not None) else x_range_limits[0])
2862
+ max_x = (x_range_default[1] if (x_range_default[1] is not None) else x_range_limits[1])
2863
+
2864
+ # Ensure "x" key exists AND list is not empty before calling min() or max()
2865
+ if (min_x is None) and ("x" in data_series) and (len(data_series["x"]) > 0):
2866
+ valid_x_values = [x for x in data_series["x"] if x is not None] # Filter out None values
2867
+ if valid_x_values: # Ensure list isn't empty after filtering
2868
+ min_x = min(valid_x_values)
2869
+
2870
+ if (max_x is None) and ("x" in data_series) and (len(data_series["x"]) > 0):
2871
+ valid_x_values = [x for x in data_series["x"] if x is not None] # Filter out None values
2872
+ if valid_x_values: # Ensure list isn't empty after filtering
2873
+ max_x = max(valid_x_values)
2874
+
2875
+ # Ensure "y" key exists AND list is not empty before calling min() or max()
2876
+ if (min_y is None) and ("y" in data_series) and (len(data_series["y"]) > 0):
2877
+ valid_y_values = [y for y in data_series["y"] if y is not None] # Filter out None values
2878
+ if valid_y_values: # Ensure list isn't empty after filtering
2879
+ min_y = min(valid_y_values)
2880
+
2881
+ if (max_y is None) and ("y" in data_series) and (len(data_series["y"]) > 0):
2882
+ valid_y_values = [y for y in data_series["y"] if y is not None] # Filter out None values
2883
+ if valid_y_values: # Ensure list isn't empty after filtering
2884
+ max_y = max(valid_y_values)
2885
+
2886
+ # Always add values to the lists, including None if applicable
2887
+ data_series_ranges["min_x"].append(min_x)
2888
+ data_series_ranges["max_x"].append(max_x)
2889
+ data_series_ranges["min_y"].append(min_y)
2890
+ data_series_ranges["max_y"].append(max_y)
2891
+
2892
+ # Filter out None values for overall min/max calculations
2893
+ valid_min_x_values = [x for x in data_series_ranges["min_x"] if x is not None]
2894
+ valid_max_x_values = [x for x in data_series_ranges["max_x"] if x is not None]
2895
+ valid_min_y_values = [y for y in data_series_ranges["min_y"] if y is not None]
2896
+ valid_max_y_values = [y for y in data_series_ranges["max_y"] if y is not None]
2897
+
2898
+ fig_dict_ranges["min_x"] = min(valid_min_x_values) if valid_min_x_values else None
2899
+ fig_dict_ranges["max_x"] = max(valid_max_x_values) if valid_max_x_values else None
2900
+ fig_dict_ranges["min_y"] = min(valid_min_y_values) if valid_min_y_values else None
2901
+ fig_dict_ranges["max_y"] = max(valid_max_y_values) if valid_max_y_values else None
2902
+
2903
+ return fig_dict_ranges, data_series_ranges
2904
+
2905
+
2906
+ # # Example usage
2907
+ # fig_dict = {
2908
+ # "data": [
2909
+ # {"x": [1, 2, 3, 4], "y": [10, 20, 30, 40]},
2910
+ # {"x": [5, 6, 7, 8], "y": [50, 60, 70, 80]},
2911
+ # {"equation": {
2912
+ # "x_range_default": [None, 500],
2913
+ # "x_range_limits": [100, 600]
2914
+ # }},
2915
+ # {"simulate": {
2916
+ # "x_range_default": [None, 700],
2917
+ # "x_range_limits": [300, 900]
2918
+ # }}
2919
+ # ]
2920
+ # }
2921
+
2922
+ # fig_dict_ranges, data_series_ranges = get_fig_dict_ranges(fig_dict, skip_equations=True, skip_simulations=True) # Skips both
2923
+ # print("Data Series Values:", data_series_ranges)
2924
+ # print("Extreme Values:", fig_dict_ranges)
2925
+
2926
+ ### Start of section of code with functions for extracting and updating x and y ranges of data series ###
1405
2927
 
1406
2928
 
1407
2929
  ### Start section of code with functions for cleaning fig_dicts for plotly compatibility ###
1408
2930
 
1409
- def update_title_field(data, depth=1, max_depth=10):
2931
+ def update_title_field(fig_dict, depth=1, max_depth=10):
1410
2932
  """ This function is intended to make JSONGrapher .json files compatible with the newer plotly recommended title field formatting
1411
- which is necessary to do things like change the font, and also necessary for being able to convert a JSONGrapher json_dict to python plotly figure objects. """
1412
- """ Recursively checks for 'title' fields and converts them to dictionary format. """
1413
- if depth > max_depth or not isinstance(data, dict):
1414
- return data
2933
+ which is necessary to do things like change the font, and also necessary for being able to convert a JSONGrapher json_dict to python plotly figure objects.
2934
+ Recursively checks for 'title' fields and converts them to dictionary format. """
2935
+ if depth > max_depth or not isinstance(fig_dict, dict):
2936
+ return fig_dict
1415
2937
 
1416
- for key, value in data.items():
2938
+ for key, value in fig_dict.items():
1417
2939
  if key == "title" and isinstance(value, str):
1418
- data[key] = {"text": value}
2940
+ fig_dict[key] = {"text": value}
1419
2941
  elif isinstance(value, dict): # Nested dictionary
1420
- data[key] = update_title_field(value, depth + 1, max_depth)
2942
+ fig_dict[key] = update_title_field(value, depth + 1, max_depth)
1421
2943
  elif isinstance(value, list): # Lists can contain nested dictionaries
1422
- data[key] = [update_title_field(item, depth + 1, max_depth) if isinstance(item, dict) else item for item in value]
1423
-
1424
- return data
2944
+ fig_dict[key] = [update_title_field(item, depth + 1, max_depth) if isinstance(item, dict) else item for item in value]
2945
+ return fig_dict
2946
+
2947
+
2948
+
2949
+
2950
+
2951
+ def convert_to_3d_layout(layout):
2952
+ import copy
2953
+ # Create a deep copy to avoid modifying the original layout
2954
+ new_layout = copy.deepcopy(layout)
2955
+
2956
+ # Add the axis fields inside `scene` first
2957
+ new_layout["scene"] = {
2958
+ "xaxis": layout.get("xaxis", {}),
2959
+ "yaxis": layout.get("yaxis", {}),
2960
+ "zaxis": layout.get("zaxis", {})
2961
+ }
2962
+
2963
+ # Remove the original axis fields from the top-level layout
2964
+ new_layout.pop("xaxis", None)
2965
+ new_layout.pop("yaxis", None)
2966
+ new_layout.pop("zaxis", None)
2967
+
2968
+ return new_layout
2969
+
2970
+ #A bubble plot uses z data, but that data is then
2971
+ #moved into the size field and the z field must be removed.
2972
+ def remove_bubble_fields(fig_dict):
2973
+ #This code will modify the data_series inside the fig_dict, directly.
2974
+ bubble_found = False #initialize with false case.
2975
+ for data_series in fig_dict["data"]:
2976
+ if "trace_style" in data_series:
2977
+ if (data_series["trace_style"] == "bubble") or ("max_bubble_size" in data_series):
2978
+ bubble_found = True
2979
+ if bubble_found == True:
2980
+ if "z" in data_series:
2981
+ data_series.pop("z")
2982
+ if "z_points" in data_series:
2983
+ data_series.pop("z_points")
2984
+ if "max_bubble_size" in data_series:
2985
+ data_series.pop("max_bubble_size")
2986
+ if bubble_found == True:
2987
+ if "zaxis" in fig_dict["layout"]:
2988
+ fig_dict["layout"].pop("zaxis")
2989
+ return fig_dict
1425
2990
 
1426
- def remove_extra_information_field(data, depth=1, max_depth=10):
2991
+ def update_3d_axes(fig_dict):
2992
+ if "zaxis" in fig_dict["layout"]:
2993
+ fig_dict['layout'] = convert_to_3d_layout(fig_dict['layout'])
2994
+ for data_series_index, data_series in enumerate(fig_dict["data"]):
2995
+ if data_series["type"] == "scatter3d":
2996
+ if "z_matrix" in data_series: #for this one, we don't want the z_matrix.
2997
+ data_series.pop("z_matrix")
2998
+ if data_series["type"] == "mesh3d":
2999
+ if "z_matrix" in data_series: #for this one, we don't want the z_matrix.
3000
+ data_series.pop("z_matrix")
3001
+ if data_series["type"] == "surface":
3002
+ if "z_matrix" in data_series: #for this one, we want the z_matrix so we pop z if we have the z_matrix..
3003
+ data_series.pop("z")
3004
+ print(" The Surface type of 3D plot has not been implemented yet. It requires replacing z with the z_matrix after the equation has been evaluated.")
3005
+ return fig_dict
3006
+
3007
+ def remove_extra_information_field(fig_dict, depth=1, max_depth=10):
1427
3008
  """ This function is intended to make JSONGrapher .json files compatible with the current plotly format expectations
1428
- and also necessary for being able to convert a JSONGRapher json_dict to python plotly figure objects. """
1429
- """Recursively checks for 'extraInformation' fields and removes them."""
1430
- if depth > max_depth or not isinstance(data, dict):
1431
- return data
3009
+ and also necessary for being able to convert a JSONGrapher json_dict to python plotly figure objects.
3010
+ Recursively checks for 'extraInformation' fields and removes them."""
3011
+ if depth > max_depth or not isinstance(fig_dict, dict):
3012
+ return fig_dict
1432
3013
 
1433
3014
  # Use a copy of the dictionary keys to safely modify the dictionary during iteration
1434
- for key in list(data.keys()):
3015
+ for key in list(fig_dict.keys()):
1435
3016
  if key == ("extraInformation" or "extra_information"):
1436
- del data[key] # Remove the field
1437
- elif isinstance(data[key], dict): # Nested dictionary
1438
- data[key] = remove_extra_information_field(data[key], depth + 1, max_depth)
1439
- elif isinstance(data[key], list): # Lists can contain nested dictionaries
1440
- data[key] = [
1441
- remove_extra_information_field(item, depth + 1, max_depth) if isinstance(item, dict) else item for item in data[key]
3017
+ del fig_dict[key] # Remove the field
3018
+ elif isinstance(fig_dict[key], dict): # Nested dictionary
3019
+ fig_dict[key] = remove_extra_information_field(fig_dict[key], depth + 1, max_depth)
3020
+ elif isinstance(fig_dict[key], list): # Lists can contain nested dictionaries
3021
+ fig_dict[key] = [
3022
+ remove_extra_information_field(item, depth + 1, max_depth) if isinstance(item, dict) else item for item in fig_dict[key]
1442
3023
  ]
1443
3024
 
1444
- return data
3025
+ return fig_dict
1445
3026
 
1446
3027
 
1447
3028
  def remove_nested_comments(data, top_level=True):
1448
3029
  """ This function is intended to make JSONGrapher .json files compatible with the current plotly format expectations
1449
- and also necessary for being able to convert a JSONGRapher json_dict to python plotly figure objects. """
1450
- """Removes 'comments' fields that are not at the top level of the JSON-dict. Starts with 'top_level = True' when dict is first passed in then becomes false after that. """
3030
+ and also necessary for being able to convert a JSONGrapher json_dict to python plotly figure objects.
3031
+ Removes 'comments' fields that are not at the top level of the JSON-dict. Starts with 'top_level = True' when dict is first passed in then becomes false after that. """
1451
3032
  if not isinstance(data, dict):
1452
3033
  return data
1453
3034
  # Process nested structures
@@ -1470,18 +3051,47 @@ def remove_simulate_field(json_fig_dict):
1470
3051
  json_fig_dict['data'] = data_dicts_list #this line shouldn't be necessary, but including it for clarity and carefulness.
1471
3052
  return json_fig_dict
1472
3053
 
1473
- def remove_custom_units_chevrons(json_fig_dict):
1474
- json_fig_dict['layout']['xaxis']['title']['text'] = json_fig_dict['layout']['xaxis']['title']['text'].replace('<','').replace('>','')
1475
- json_fig_dict['layout']['yaxis']['title']['text'] = json_fig_dict['layout']['yaxis']['title']['text'].replace('<','').replace('>','')
3054
+ def remove_equation_field(json_fig_dict):
3055
+ data_dicts_list = json_fig_dict['data']
3056
+ for data_dict in data_dicts_list:
3057
+ data_dict.pop('equation', None) #Some people recommend using pop over if/del as safer. Both ways should work under normal circumstances.
3058
+ json_fig_dict['data'] = data_dicts_list #this line shouldn't be necessary, but including it for clarity and carefulness.
1476
3059
  return json_fig_dict
1477
3060
 
3061
+ def remove_trace_style_field(json_fig_dict):
3062
+ data_dicts_list = json_fig_dict['data']
3063
+ for data_dict in data_dicts_list:
3064
+ data_dict.pop('trace_style', None) #Some people recommend using pop over if/del as safer. Both ways should work under normal circumstances.
3065
+ data_dict.pop('tracetype', None) #Some people recommend using pop over if/del as safer. Both ways should work under normal circumstances.
3066
+ json_fig_dict['data'] = data_dicts_list #this line shouldn't be necessary, but including it for clarity and carefulness.
3067
+ return json_fig_dict
3068
+
3069
+ def remove_custom_units_chevrons(json_fig_dict):
3070
+ try:
3071
+ json_fig_dict['layout']['xaxis']['title']['text'] = json_fig_dict['layout']['xaxis']['title']['text'].replace('<','').replace('>','')
3072
+ except KeyError:
3073
+ pass
3074
+ try:
3075
+ json_fig_dict['layout']['yaxis']['title']['text'] = json_fig_dict['layout']['yaxis']['title']['text'].replace('<','').replace('>','')
3076
+ except KeyError:
3077
+ pass
3078
+ try:
3079
+ json_fig_dict['layout']['zaxis']['title']['text'] = json_fig_dict['layout']['zaxis']['title']['text'].replace('<','').replace('>','')
3080
+ except KeyError:
3081
+ pass
3082
+ return json_fig_dict
1478
3083
 
1479
- def clean_json_fig_dict(json_fig_dict, fields_to_update=["title_field", "extraInformation", "nested_comments"]):
3084
+ def clean_json_fig_dict(json_fig_dict, fields_to_update=None):
1480
3085
  """ This function is intended to make JSONGrapher .json files compatible with the current plotly format expectations
1481
- and also necessary for being able to convert a JSONGRapher json_dict to python plotly figure objects.
3086
+ and also necessary for being able to convert a JSONGrapher json_dict to python plotly figure objects.
3087
+ fields_to_update should be a list.
1482
3088
  This function can also remove the 'simulate' field from data series. However, that is not the default behavior
1483
3089
  because one would not want to do that by mistake before simulation is performed.
3090
+ This function can also remove the 'equation' field from data series. However, that is not the default behavior
3091
+ because one would not want to do that by mistake before the equation is evaluated.
1484
3092
  """
3093
+ if fields_to_update is None: # should not initialize mutable objects in arguments line, so doing here.
3094
+ fields_to_update = ["title_field", "extraInformation", "nested_comments"]
1485
3095
  fig_dict = json_fig_dict
1486
3096
  #unmodified_data = copy.deepcopy(data)
1487
3097
  if "title_field" in fields_to_update:
@@ -1492,14 +3102,24 @@ def clean_json_fig_dict(json_fig_dict, fields_to_update=["title_field", "extraIn
1492
3102
  fig_dict = remove_nested_comments(fig_dict)
1493
3103
  if "simulate" in fields_to_update:
1494
3104
  fig_dict = remove_simulate_field(fig_dict)
3105
+ if "equation" in fields_to_update:
3106
+ fig_dict = remove_equation_field(fig_dict)
1495
3107
  if "custom_units_chevrons" in fields_to_update:
1496
3108
  fig_dict = remove_custom_units_chevrons(fig_dict)
3109
+ if "bubble" in fields_to_update: #must be updated before trace_style is removed.
3110
+ fig_dict = remove_bubble_fields(fig_dict)
3111
+ if "trace_style" in fields_to_update:
3112
+ fig_dict = remove_trace_style_field(fig_dict)
3113
+ if "3d_axes" in fields_to_update: #This is for 3D plots
3114
+ fig_dict = update_3d_axes(fig_dict)
1497
3115
 
1498
3116
  return fig_dict
1499
3117
 
1500
3118
  ### End section of code with functions for cleaning fig_dicts for plotly compatibility ###
1501
3119
 
1502
- ### Beginning of section of file that has functions for calling external javascript simulators ###
3120
+ ### Beginning of section of file that has functions for "simulate" and "equation" fields, to evaluate equations and call external javascript simulators, as well as support functions ###
3121
+
3122
+ local_python_functions_dictionary = {} #This is a global variable that works with the "simulate" feature and lets users call python functions for data generation.
1503
3123
 
1504
3124
  def run_js_simulation(javascript_simulator_url, simulator_input_json_dict, verbose = False):
1505
3125
  """
@@ -1527,7 +3147,7 @@ def run_js_simulation(javascript_simulator_url, simulator_input_json_dict, verbo
1527
3147
  """
1528
3148
  import requests
1529
3149
  import subprocess
1530
- import json
3150
+ #import json
1531
3151
  import os
1532
3152
 
1533
3153
  # Convert to raw GitHub URL only if "raw" is not in the original URL
@@ -1542,14 +3162,14 @@ def run_js_simulation(javascript_simulator_url, simulator_input_json_dict, verbo
1542
3162
  js_filename = os.path.basename(javascript_simulator_url)
1543
3163
 
1544
3164
  # Download the JavaScript file
1545
- response = requests.get(javascript_simulator_url)
3165
+ response = requests.get(javascript_simulator_url, timeout=300)
1546
3166
 
1547
3167
  if response.status_code == 200:
1548
- with open(js_filename, "w") as file:
3168
+ with open(js_filename, "w", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
1549
3169
  file.write(response.text)
1550
3170
 
1551
3171
  # Append the export statement to the JavaScript file
1552
- with open(js_filename, "a") as file:
3172
+ with open(js_filename, "a", encoding="utf-8") as file: # Specify UTF-8 encoding for compatibility
1553
3173
  file.write("\nmodule.exports = { simulate };")
1554
3174
 
1555
3175
  # Convert input dictionary to a JSON string
@@ -1561,7 +3181,7 @@ def run_js_simulation(javascript_simulator_url, simulator_input_json_dict, verbo
1561
3181
  console.log(JSON.stringify(simulator.simulate({input_json_str})));
1562
3182
  """
1563
3183
 
1564
- result = subprocess.run(["node", "-e", js_command], capture_output=True, text=True)
3184
+ result = subprocess.run(["node", "-e", js_command], capture_output=True, text=True, check=True)
1565
3185
 
1566
3186
  # Print output and errors if verbose
1567
3187
  if verbose:
@@ -1610,57 +3230,279 @@ def convert_to_raw_github_url(url):
1610
3230
  return url # Return unchanged if not a GitHub file URL
1611
3231
 
1612
3232
  #This function takes in a data_series_dict object and then
1613
- #calls an external javascript simulation if needed
3233
+ #calls an external python or javascript simulation if needed
1614
3234
  #Then fills the data_series dict with the simulated data.
3235
+ #This function is not intended to be called by the regular user
3236
+ #because it returns extra fields that need to be parsed out.
3237
+ #and because it does not do unit conversions as needed after the simulation resultss are returned.
1615
3238
  def simulate_data_series(data_series_dict, simulator_link='', verbose=False):
1616
3239
  if simulator_link == '':
1617
- simulator_link = data_series_dict["simulate"]["model"]
1618
- #need to provide the link and the data_dict
1619
- simulation_return = run_js_simulation(simulator_link, data_series_dict, verbose = verbose)
1620
- data_series_dict_filled = simulation_return["data"]
1621
- return data_series_dict_filled
3240
+ simulator_link = data_series_dict["simulate"]["model"]
3241
+ if simulator_link == "local_python": #this is the local python case.
3242
+ #Here, I haev split up the lines of code more than needed so that the logic is easy to follow.
3243
+ simulation_function_label = data_series_dict["simulate"]["simulation_function_label"]
3244
+ simulation_function = local_python_functions_dictionary[simulation_function_label]
3245
+ simulation_return = simulation_function(data_series_dict)
3246
+ if "data" in simulation_return: #the simulation return should have the data_series_dict in another dictionary.
3247
+ simulation_result = simulation_return["data"]
3248
+ else: #if there is no "data" field, we will assume that only the data_series_dict has been returned.
3249
+ simulation_result = simulation_return
3250
+ return simulation_result
3251
+ try:
3252
+ simulation_return = run_js_simulation(simulator_link, data_series_dict, verbose=verbose)
3253
+ if isinstance(simulation_return, dict) and "error" in simulation_return: # Check for errors in the returned data
3254
+ print(f"Simulation failed: {simulation_return.get('error_message', 'Unknown error')}")
3255
+ print(simulation_return)
3256
+ return None
3257
+ return simulation_return.get("data", None)
3258
+
3259
+ except Exception as e: # This is so VS code pylint does not flag this line. pylint: disable=broad-except
3260
+ print(f"Exception occurred in simulate_data_series function of JSONRecordCreator.py: {e}")
3261
+ return None
1622
3262
 
1623
3263
  #Function that goes through a fig_dict data series and simulates each data series as needed.
1624
- #could probably change this into a loop that calls simulate_specific_data_series_by_index
1625
3264
  #If the simulated data returned has "x_label" and/or "y_label" with units, those will be used to scale the data, then will be removed.
1626
3265
  def simulate_as_needed_in_fig_dict(fig_dict, simulator_link='', verbose=False):
3266
+ data_dicts_list = fig_dict['data']
3267
+ for data_dict_index in range(len(data_dicts_list)):
3268
+ fig_dict = simulate_specific_data_series_by_index(fig_dict, data_dict_index, simulator_link=simulator_link, verbose=verbose)
3269
+ return fig_dict
3270
+
3271
+ #Function that takes fig_dict and dataseries index and simulates if needed. Also performs unit conversions as needed.
3272
+ #If the simulated data returned has "x_label" and/or "y_label" with units, those will be used to scale the data, then will be removed.
3273
+ def simulate_specific_data_series_by_index(fig_dict, data_series_index, simulator_link='', verbose=False):
3274
+ data_dicts_list = fig_dict['data']
3275
+ data_dict_index = data_series_index
3276
+ data_dict = data_dicts_list[data_dict_index]
3277
+ if 'simulate' in data_dict:
3278
+ data_dict_filled = simulate_data_series(data_dict, simulator_link=simulator_link, verbose=verbose)
3279
+ # Check if unit scaling is needed
3280
+ if ("x_label" in data_dict_filled) or ("y_label" in data_dict_filled):
3281
+ #first, get the units that are in the layout of fig_dict so we know what to convert to.
3282
+ existing_record_x_label = fig_dict["layout"]["xaxis"]["title"]["text"]
3283
+ existing_record_y_label = fig_dict["layout"]["yaxis"]["title"]["text"]
3284
+ # Extract units from the simulation output.
3285
+ existing_record_x_units = separate_label_text_from_units(existing_record_x_label).get("units", "")
3286
+ existing_record_y_units = separate_label_text_from_units(existing_record_y_label).get("units", "")
3287
+ simulated_data_series_x_units = separate_label_text_from_units(data_dict_filled.get('x_label', '')).get("units", "")
3288
+ simulated_data_series_y_units = separate_label_text_from_units(data_dict_filled.get('y_label', '')).get("units", "")
3289
+ # Compute unit scaling ratios
3290
+ x_units_ratio = get_units_scaling_ratio(simulated_data_series_x_units, existing_record_x_units) if simulated_data_series_x_units and existing_record_x_units else 1
3291
+ y_units_ratio = get_units_scaling_ratio(simulated_data_series_y_units, existing_record_y_units) if simulated_data_series_y_units and existing_record_y_units else 1
3292
+ # Apply scaling to the data series
3293
+ scale_dataseries_dict(data_dict_filled, num_to_scale_x_values_by=x_units_ratio, num_to_scale_y_values_by=y_units_ratio)
3294
+ #Verbose logging for debugging
3295
+ if verbose:
3296
+ print(f"Scaling X values by: {x_units_ratio}, Scaling Y values by: {y_units_ratio}")
3297
+ #Now need to remove the "x_label" and "y_label" to be compatible with plotly.
3298
+ data_dict_filled.pop("x_label", None)
3299
+ data_dict_filled.pop("y_label", None)
3300
+ # Update the figure dictionary
3301
+ data_dicts_list[data_dict_index] = data_dict_filled
3302
+ fig_dict['data'] = data_dicts_list
3303
+ return fig_dict
3304
+
3305
+ def evaluate_equations_as_needed_in_fig_dict(fig_dict):
1627
3306
  data_dicts_list = fig_dict['data']
1628
3307
  for data_dict_index, data_dict in enumerate(data_dicts_list):
1629
- if 'simulate' in data_dict:
1630
- data_dict_filled = simulate_data_series(data_dict, simulator_link=simulator_link, verbose=verbose)
1631
- #data_dict_filled may include "x_label" and/or "y_label". If it does, we'll need to check about scaling units.
1632
- if (("x_label" in data_dict_filled) or ("y_label" in data_dict_filled)):
1633
- #first, get the units that are in the layout of fig_dict so we know what to convert to.
1634
- existing_record_x_label = fig_dict["layout"]["xaxis"]["title"]["text"] #this is a dictionary.
1635
- existing_record_y_label = fig_dict["layout"]["yaxis"]["title"]["text"] #this is a dictionary.
1636
- existing_record_x_units = separate_label_text_from_units(existing_record_x_label)["units"]
1637
- existing_record_y_units = separate_label_text_from_units(existing_record_y_label)["units"]
1638
- #now, get the units from the simulation output.
3308
+ if 'equation' in data_dict:
3309
+ fig_dict = evaluate_equation_for_data_series_by_index(fig_dict, data_dict_index)
3310
+ return fig_dict
3311
+
3312
+ #TODO: Should add z units ratio scaling here (just to change units when merging records). Should do the same for the simulate_specific_data_series_by_index function.
3313
+ def evaluate_equation_for_data_series_by_index(fig_dict, data_series_index, verbose="auto"):
3314
+ try:
3315
+ # Attempt to import from the json_equationer package
3316
+ import json_equationer.equation_creator as equation_creator
3317
+ except ImportError:
3318
+ try:
3319
+ # Fallback: attempt local import
3320
+ from . import equation_creator
3321
+ except ImportError as exc:
3322
+ # Log the failure and handle gracefully
3323
+ print(f"Failed to import equation_creator: {exc}")
3324
+ import copy
3325
+ data_dicts_list = fig_dict['data']
3326
+ data_dict = data_dicts_list[data_series_index]
3327
+ if 'equation' in data_dict:
3328
+ equation_object = equation_creator.Equation(data_dict['equation'])
3329
+ if verbose == "auto":
3330
+ equation_dict_evaluated = equation_object.evaluate_equation()
3331
+ else:
3332
+ equation_dict_evaluated = equation_object.evaluate_equation(verbose=verbose)
3333
+ if "graphical_dimensionality" in equation_dict_evaluated:
3334
+ graphical_dimensionality = equation_dict_evaluated["graphical_dimensionality"]
3335
+ else:
3336
+ graphical_dimensionality = 2
3337
+ data_dict_filled = copy.deepcopy(data_dict)
3338
+ data_dict_filled['equation'] = equation_dict_evaluated
3339
+ data_dict_filled['x_label'] = data_dict_filled['equation']['x_variable']
3340
+ data_dict_filled['y_label'] = data_dict_filled['equation']['y_variable']
3341
+ data_dict_filled['x'] = equation_dict_evaluated['x_points']
3342
+ data_dict_filled['y'] = equation_dict_evaluated['y_points']
3343
+ if graphical_dimensionality == 3:
3344
+ data_dict_filled['z_label'] = data_dict_filled['equation']['z_variable']
3345
+ data_dict_filled['z'] = equation_dict_evaluated['z_points']
3346
+ #data_dict_filled may include "x_label" and/or "y_label". If it does, we'll need to check about scaling units.
3347
+ if (("x_label" in data_dict_filled) or ("y_label" in data_dict_filled)) or ("z_label" in data_dict_filled):
3348
+ #first, get the units that are in the layout of fig_dict so we know what to convert to.
3349
+ existing_record_x_label = fig_dict["layout"]["xaxis"]["title"]["text"] #this is a dictionary.
3350
+ existing_record_y_label = fig_dict["layout"]["yaxis"]["title"]["text"] #this is a dictionary.
3351
+ existing_record_x_units = separate_label_text_from_units(existing_record_x_label)["units"]
3352
+ existing_record_y_units = separate_label_text_from_units(existing_record_y_label)["units"]
3353
+ if "z_label" in data_dict_filled:
3354
+ existing_record_z_label = fig_dict["layout"]["zaxis"]["title"]["text"] #this is a dictionary.
3355
+ if (existing_record_x_units == '') and (existing_record_y_units == ''): #skip scaling if there are no units.
3356
+ pass
3357
+ else: #If we will be scaling...
3358
+ #now, get the units from the evaluated equation output.
1639
3359
  simulated_data_series_x_units = separate_label_text_from_units(data_dict_filled['x_label'])["units"]
1640
3360
  simulated_data_series_y_units = separate_label_text_from_units(data_dict_filled['y_label'])["units"]
1641
3361
  x_units_ratio = get_units_scaling_ratio(simulated_data_series_x_units, existing_record_x_units)
1642
3362
  y_units_ratio = get_units_scaling_ratio(simulated_data_series_y_units, existing_record_y_units)
1643
3363
  #We scale the dataseries, which really should be a function.
1644
3364
  scale_dataseries_dict(data_dict_filled, num_to_scale_x_values_by = x_units_ratio, num_to_scale_y_values_by = y_units_ratio)
1645
- #Now need to remove the "x_label" and "y_label" to be compatible with plotly.
1646
- data_dict_filled.pop("x_label", None)
1647
- data_dict_filled.pop("y_label", None)
1648
- data_dicts_list[data_dict_index] = data_dict_filled
3365
+ #Now need to remove the "x_label" and "y_label" to be compatible with plotly.
3366
+ data_dict_filled.pop("x_label", None)
3367
+ data_dict_filled.pop("y_label", None)
3368
+ if "z_label" in data_dict_filled:
3369
+ data_dict_filled.pop("z_label", None)
3370
+ if "type" not in data_dict:
3371
+ if graphical_dimensionality == 2:
3372
+ data_dict_filled['type'] = 'spline'
3373
+ elif graphical_dimensionality == 3:
3374
+ data_dict_filled['type'] = 'mesh3d'
3375
+ data_dicts_list[data_series_index] = data_dict_filled
1649
3376
  fig_dict['data'] = data_dicts_list
1650
3377
  return fig_dict
1651
3378
 
1652
- #Function that takes fig_dict and dataseries index and simulates if needed.
1653
- def simulate_specific_data_series_by_index(fig_dict, data_series_index, simulator_link='', verbose=False):
1654
- data_dicts_list = fig_dict['data']
1655
- data_dict_index = data_series_index
1656
- data_dict = data_dicts_list[data_dict_index]
1657
- if 'simulate' in data_dict:
1658
- data_dict_filled = simulate_data_series(data_dict, simulator_link=simulator_link, verbose=verbose)
1659
- data_dicts_list[data_dict_index] = data_dict_filled
1660
- fig_dict['data'] = data_dicts_list
3379
+
3380
+ def update_implicit_data_series_data(target_fig_dict, source_fig_dict, parallel_structure=True, modify_target_directly = False):
3381
+ """
3382
+ Updates the x and y values of implicit data series (equation/simulate) in target_fig_dict
3383
+ using values from the corresponding series in source_fig_dict.
3384
+
3385
+ Args:
3386
+ target_fig_dict (dict): The figure dictionary that needs updated data.
3387
+ source_fig_dict (dict): The figure dictionary that provides x and y values.
3388
+ parallel_structure (bool, optional): If True, assumes both data lists are the same
3389
+ length and updates using zip(). If False,
3390
+ matches by name instead. Default is True.
3391
+
3392
+ Returns:
3393
+ dict: A new figure dictionary with updated x and y values for implicit data series.
3394
+
3395
+ Notes:
3396
+ - If parallel_structure=True and both lists have the same length, updates use zip().
3397
+ - If parallel_structure=False, matching is done by the "name" field.
3398
+ - Only updates data series that contain "simulate" or "equation".
3399
+ - Ensures deep copying to avoid modifying the original structures.
3400
+ """
3401
+ if modify_target_directly == False:
3402
+ import copy # Import inside function to limit scope
3403
+ updated_fig_dict = copy.deepcopy(target_fig_dict) # Deep copy to avoid modifying original
3404
+ else:
3405
+ updated_fig_dict = target_fig_dict
3406
+
3407
+ target_data_series = updated_fig_dict.get("data", [])
3408
+ source_data_series = source_fig_dict.get("data", [])
3409
+
3410
+ if parallel_structure and len(target_data_series) == len(source_data_series):
3411
+ # Use zip() when parallel_structure=True and lengths match
3412
+ for target_series, source_series in zip(target_data_series, source_data_series):
3413
+ if ("equation" in target_series) or ("simulate" in target_series):
3414
+ target_series["x"] = source_series.get("x", []) # Extract and apply "x" values
3415
+ target_series["y"] = source_series.get("y", []) # Extract and apply "y" values
3416
+ if "z" in source_series:
3417
+ target_series["z"] = source_series.get("z", []) # Extract and apply "z" values
3418
+ else:
3419
+ # Match by name when parallel_structure=False or lengths differ
3420
+ source_data_dict = {series["name"]: series for series in source_data_series if "name" in series}
3421
+
3422
+ for target_series in target_data_series:
3423
+ if ("equation" in target_series) or ("simulate" in target_series):
3424
+ target_name = target_series.get("name")
3425
+
3426
+ if target_name in source_data_dict:
3427
+ source_series = source_data_dict[target_name]
3428
+ target_series["x"] = source_series.get("x", []) # Extract and apply "x" values
3429
+ target_series["y"] = source_series.get("y", []) # Extract and apply "y" values
3430
+ if "z" in source_series:
3431
+ target_series["z"] = source_series.get("z", []) # Extract and apply "z" values
3432
+ return updated_fig_dict
3433
+
3434
+
3435
+ def execute_implicit_data_series_operations(fig_dict, simulate_all_series=True, evaluate_all_equations=True, adjust_implicit_data_ranges=True):
3436
+ """
3437
+ This function is designed to be called during creation of a plotly or matplotlib figure creation.
3438
+ Processes implicit data series (equation/simulate), adjusting ranges, performing simulations,
3439
+ and evaluating equations as needed.
3440
+
3441
+ The important thing is that this function creates a "fresh" fig_dict, does some manipulation, then then gets the data from that
3442
+ and adds it to the original fig_dict.
3443
+ That way the original fig_dict is not changed other than getting the simulated/evaluated data.
3444
+
3445
+ The reason the function works this way is that the x_range_default of the implicit data series (equations and simulations)
3446
+ are adjusted to match the data in the fig_dict, but we don't want to change the x_range_default of our main record.
3447
+ That's why we make a copy for creating simulated/evaluated data from those adjusted ranges, and then put the simulated/evaluated data
3448
+ back into the original dict.
3449
+
3450
+
3451
+
3452
+ Args:
3453
+ fig_dict (dict): The figure dictionary containing data series.
3454
+ simulate_all_series (bool): If True, performs simulations for applicable series.
3455
+ evaluate_all_equations (bool): If True, evaluates all equation-based series.
3456
+ adjust_implicit_data_ranges (bool): If True, modifies ranges for implicit data series.
3457
+
3458
+ Returns:
3459
+ dict: Updated figure dictionary with processed implicit data series.
3460
+
3461
+ Notes:
3462
+ - If adjust_implicit_data_ranges=True, retrieves min/max values from regular data series
3463
+ (those that are not equations and not simulations) and applies them to implicit data.
3464
+ - If simulate_all_series=True, executes simulations for all series that require them
3465
+ and transfers the computed data back to fig_dict without copying ranges.
3466
+ - If evaluate_all_equations=True, solves equations as needed and transfers results
3467
+ back to fig_dict without copying ranges.
3468
+ - Uses deepcopy to avoid modifying the original input dictionary.
3469
+ """
3470
+ import copy # Import inside function for modularity
3471
+
3472
+ # Create a copy for processing implicit series separately
3473
+ fig_dict_for_implicit = copy.deepcopy(fig_dict)
3474
+ #first check if any data_series have an equatinon or simulation field. If not, we'll skip.
3475
+ #initialize with false:
3476
+ implicit_series_present = False
3477
+
3478
+ for data_series in fig_dict["data"]:
3479
+ if ("equation" in data_series) or ("simulate" in data_series):
3480
+ implicit_series_present = True
3481
+ if implicit_series_present == True:
3482
+ if adjust_implicit_data_ranges:
3483
+ # Retrieve ranges from data series that are not equation-based or simulation-based.
3484
+ fig_dict_ranges, data_series_ranges = get_fig_dict_ranges(fig_dict, skip_equations=True, skip_simulations=True)
3485
+ data_series_ranges # Variable not used. The remainder of this comment is to avoid vs code pylint flagging. pylint: disable=pointless-statement
3486
+ # Apply the extracted ranges to implicit data series before simulation or equation evaluation.
3487
+ fig_dict_for_implicit = update_implicit_data_series_x_ranges(fig_dict, fig_dict_ranges)
3488
+
3489
+ if simulate_all_series:
3490
+ # Perform simulations for applicable series
3491
+ fig_dict_for_implicit = simulate_as_needed_in_fig_dict(fig_dict_for_implicit)
3492
+ # Copy data back to fig_dict, ensuring ranges remain unchanged
3493
+ fig_dict = update_implicit_data_series_data(target_fig_dict=fig_dict, source_fig_dict=fig_dict_for_implicit, parallel_structure=True, modify_target_directly=True)
3494
+
3495
+ if evaluate_all_equations:
3496
+ # Evaluate equations that require computation
3497
+ fig_dict_for_implicit = evaluate_equations_as_needed_in_fig_dict(fig_dict_for_implicit)
3498
+ # Copy results back without overwriting the ranges
3499
+ fig_dict = update_implicit_data_series_data(target_fig_dict=fig_dict, source_fig_dict=fig_dict_for_implicit, parallel_structure=True, modify_target_directly=True)
3500
+
1661
3501
  return fig_dict
1662
3502
 
1663
- ### End of section of file that has functions for calling external javascript simulators ###
3503
+
3504
+
3505
+ ### End of section of file that has functions for "simulate" and "equation" fields, to evaluate equations and call external javascript simulators, as well as support functions###
1664
3506
 
1665
3507
  # Example Usage
1666
3508
  if __name__ == "__main__":
@@ -1669,7 +3511,7 @@ if __name__ == "__main__":
1669
3511
  comments="Here is a description.",
1670
3512
  graph_title="Here Is The Graph Title Spot",
1671
3513
  data_objects_list=[
1672
- {"comments": "Initial data series.", "uid": "123", "name": "Series A", "type": "spline", "x": [1, 2, 3], "y": [4, 5, 8]}
3514
+ {"comments": "Initial data series.", "uid": "123", "name": "Series A", "trace_style": "spline", "x": [1, 2, 3], "y": [4, 5, 8]}
1673
3515
  ],
1674
3516
  )
1675
3517
  x_label_including_units= "Time (years)"
@@ -1685,14 +3527,14 @@ if __name__ == "__main__":
1685
3527
  print(Record)
1686
3528
 
1687
3529
  # Example of creating a record from an existing dictionary.
1688
- existing_JSONGrapher_record = {
3530
+ example_existing_JSONGrapher_record = {
1689
3531
  "comments": "Existing record description.",
1690
3532
  "graph_title": "Existing Graph",
1691
3533
  "data": [
1692
3534
  {"comments": "Data series 1", "uid": "123", "name": "Series A", "type": "spline", "x": [1, 2, 3], "y": [4, 5, 8]}
1693
3535
  ],
1694
3536
  }
1695
- Record_from_existing = JSONGrapherRecord(existing_JSONGrapher_record=existing_JSONGrapher_record)
3537
+ Record_from_existing = JSONGrapherRecord(existing_JSONGrapher_record=example_existing_JSONGrapher_record)
1696
3538
  x_label_including_units= "Time (years)"
1697
3539
  y_label_including_units = "Height (cm)"
1698
3540
  Record_from_existing.set_comments("Tree Growth Data collected from the US National Arboretum")
@@ -1701,7 +3543,8 @@ if __name__ == "__main__":
1701
3543
  Record_from_existing.set_y_axis_label_including_units(y_label_including_units)
1702
3544
  print(Record_from_existing)
1703
3545
 
1704
- print("NOW WILL MERGE THE RECORDS, AND USE THE SECOND ONE TWICE (AS A JSONGRAPHER OBJECT THEN JUST THE FIG_DICT)")
3546
+ print("NOW WILL MERGE THE RECORDS, AND USE THE SECOND ONE TWICE (AS A JSONGrapher OBJECT THEN JUST THE FIG_DICT)")
1705
3547
  print(merge_JSONGrapherRecords([Record, Record_from_existing, Record_from_existing.fig_dict]))
1706
3548
 
1707
3549
 
3550
+