jsongrapher 1.6__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.
- JSONGrapher/JSONRecordCreator.py +894 -0
- JSONGrapher/__init__.py +3 -0
- JSONGrapher/units_list.py +450 -0
- jsongrapher-1.6.data/data/LICENSE +24 -0
- jsongrapher-1.6.data/data/README.md +79 -0
- jsongrapher-1.6.dist-info/LICENSE +24 -0
- jsongrapher-1.6.dist-info/METADATA +101 -0
- jsongrapher-1.6.dist-info/RECORD +10 -0
- jsongrapher-1.6.dist-info/WHEEL +5 -0
- jsongrapher-1.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,894 @@
|
|
1
|
+
import json
|
2
|
+
#TODO: put an option to suppress warnings from JSONRecordCreator
|
3
|
+
|
4
|
+
|
5
|
+
#the function create_new_JSONGrapherRecord is intended to be "like" a wrapper function for people who find it more
|
6
|
+
# intuitive to create class objects that way, this variable is actually just a reference
|
7
|
+
# so that we don't have to map the arguments.
|
8
|
+
def create_new_JSONGrapherRecord(hints=False):
|
9
|
+
#we will create a new record. While we could populate it with the init,
|
10
|
+
#we will use the functions since it makes thsi function a bit easier to follow.
|
11
|
+
new_record = JSONGrapherRecord()
|
12
|
+
if hints == True:
|
13
|
+
new_record.add_hints()
|
14
|
+
return new_record
|
15
|
+
|
16
|
+
|
17
|
+
class JSONGrapherRecord:
|
18
|
+
"""
|
19
|
+
This class enables making JSONGrapher records. Each instance represents a structured JSON record for a graph.
|
20
|
+
One can optionally provide an existing JSONGrapher record during creation to pre-populate the object.
|
21
|
+
|
22
|
+
Arguments & Attributes (all are optional):
|
23
|
+
comments (str): General description or metadata related to the entire record. Can include citation links. Goes into the record's top level comments field.
|
24
|
+
datatype: The datatype is the experiment type or similar, it 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. This ends up being the datatype field of the full JSONGrapher file. Avoid using double underscores '__' in this field unless you have read the manual about hierarchical datatypes.
|
25
|
+
graph_title: Title of the graph or the dataset being represented.
|
26
|
+
data_objects_list (list): List of data series dictionaries to pre-populate the record.
|
27
|
+
x_data: Single series x data in a list or array-like structure.
|
28
|
+
y_data: Single series y data in a list or array-like structure.
|
29
|
+
x_axis_label_including_units: A string with units provided in parentheses. Use of multiplication "*" and division "/" and parentheses "( )" are allowed within in the units .
|
30
|
+
y_axis_label_including_units: A string with units provided in parentheses. Use of multiplication "*" and division "/" and parentheses "( )" are allowed within in the units .
|
31
|
+
layout: A dictionary defining the layout of the graph, including axis titles,
|
32
|
+
comments, and general formatting options.
|
33
|
+
|
34
|
+
Methods:
|
35
|
+
add_data_series: Adds a new data series to the record.
|
36
|
+
set_layout: Updates the layout configuration for the graph.
|
37
|
+
export_to_json_file: Saves the entire record (comments, datatype, data, layout) as a JSON file.
|
38
|
+
populate_from_existing_record: Populates the attributes from an existing JSONGrapher record.
|
39
|
+
"""
|
40
|
+
|
41
|
+
def __init__(self, comments="", graph_title="", datatype="", data_objects_list = None, x_data=None, y_data=None, x_axis_label_including_units="", y_axis_label_including_units ="", plot_type ="", layout={}, existing_JSONGrapher_record=None):
|
42
|
+
"""
|
43
|
+
Initialize a JSONGrapherRecord instance with optional attributes or an existing record.
|
44
|
+
|
45
|
+
layout (dict): Layout dictionary to pre-populate the graph configuration.
|
46
|
+
existing_JSONGrapher_record (dict): Existing JSONGrapher record to populate the instance.
|
47
|
+
"""
|
48
|
+
# Default attributes for a new record.
|
49
|
+
# Initialize the main record dictionary
|
50
|
+
# 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.
|
51
|
+
|
52
|
+
#if receiving a data_objects_list, validate it.
|
53
|
+
if data_objects_list:
|
54
|
+
validate_plotly_data_list(data_objects_list) #call a function from outside the class.
|
55
|
+
#if receiving axis labels, validate them.
|
56
|
+
if x_axis_label_including_units:
|
57
|
+
validate_JSONGrapher_axis_label(x_axis_label_including_units, axis_name="x", remove_plural_units=False)
|
58
|
+
if y_axis_label_including_units:
|
59
|
+
validate_JSONGrapher_axis_label(y_axis_label_including_units, axis_name="y", remove_plural_units=False)
|
60
|
+
|
61
|
+
self.fig_dict = {
|
62
|
+
"comments": comments, # Top-level comments
|
63
|
+
"datatype": datatype, # Top-level datatype (datatype)
|
64
|
+
"data": data_objects_list if data_objects_list else [], # Data series list
|
65
|
+
"layout": layout if layout else {
|
66
|
+
"title": graph_title,
|
67
|
+
"xaxis": {"title": x_axis_label_including_units},
|
68
|
+
"yaxis": {"title": y_axis_label_including_units}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
self.plot_type = plot_type #the plot_type is 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.
|
73
|
+
if plot_type != "":
|
74
|
+
self.fig_dict["plot_type"] = plot_type
|
75
|
+
|
76
|
+
# Populate attributes if an existing JSONGrapher record is provided.
|
77
|
+
if existing_JSONGrapher_record:
|
78
|
+
self.populate_from_existing_record(existing_JSONGrapher_record)
|
79
|
+
|
80
|
+
# Initialize the hints dictionary, for use later, since the actual locations in the JSONRecord can be non-intuitive.
|
81
|
+
self.hints_dictionary = {}
|
82
|
+
# Adding hints. Here, the keys are the full field locations within the record.
|
83
|
+
self.hints_dictionary["['comments']"] = "Use Record.set_comments() to populate this field. Put in a general description or metadata related to the entire record. Can include citation links. Goes into the record's top level comments field."
|
84
|
+
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."
|
85
|
+
self.hints_dictionary["['layout']['title']"] = "Use Record.set_graph_title() to populate this field. This is the title for the graph."
|
86
|
+
self.hints_dictionary["['layout']['xaxis']['title']"] = "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
|
87
|
+
self.hints_dictionary["['layout']['yaxis']['title']"] = "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'< >'."
|
88
|
+
|
89
|
+
|
90
|
+
#this function enables printing the current record.
|
91
|
+
def __str__(self):
|
92
|
+
"""
|
93
|
+
Returns a JSON-formatted string of the record with an indent of 4.
|
94
|
+
"""
|
95
|
+
print("Warning: Printing directly will return the raw record without some automatic updates. Please use the syntax RecordObject.print_to_inspect() which will make automatic consistency updates and validation checks to the record before printing.")
|
96
|
+
return json.dumps(self.fig_dict, indent=4)
|
97
|
+
|
98
|
+
|
99
|
+
def add_data_series(self, series_name, x_values=[], y_values=[], simulate={}, comments="", plot_type="", uid="", line="", extra_fields=None):
|
100
|
+
"""
|
101
|
+
This is the normal way of adding an x,y data series.
|
102
|
+
"""
|
103
|
+
# series_name: Name of the data series.
|
104
|
+
# x: List of x-axis values. Or similar structure.
|
105
|
+
# y: List of y-axis values. Or similar structure.
|
106
|
+
# simulate: This is an optional field which, if used, is a JSON object with entries for calling external simulation scripts.
|
107
|
+
# comments: Optional description of the data series.
|
108
|
+
# plot_type: Type of the data (e.g., scatter, line).
|
109
|
+
# line: Dictionary describing line properties (e.g., shape, width).
|
110
|
+
# uid: Optional unique identifier for the series (e.g., a DOI).
|
111
|
+
# extra_fields: Dictionary containing additional fields to add to the series.
|
112
|
+
x_values = list(x_values)
|
113
|
+
y_values = list(y_values)
|
114
|
+
|
115
|
+
data_series_dict = {
|
116
|
+
"name": series_name,
|
117
|
+
"x": x_values,
|
118
|
+
"y": y_values,
|
119
|
+
}
|
120
|
+
|
121
|
+
#Add optional inputs.
|
122
|
+
if len(comments) > 0:
|
123
|
+
data_series_dict["comments"]: comments
|
124
|
+
if len(uid) > 0:
|
125
|
+
data_series_dict["uid"]: uid
|
126
|
+
if len(line) > 0:
|
127
|
+
data_series_dict["line"]: line
|
128
|
+
#add simulate field if included.
|
129
|
+
if simulate:
|
130
|
+
data_series_dict["simulate"] = simulate
|
131
|
+
# Add extra fields if provided, they will be added.
|
132
|
+
if extra_fields:
|
133
|
+
data_series_dict.update(extra_fields)
|
134
|
+
#Add to the class object's data list.
|
135
|
+
self.fig_dict["data"].append(data_series_dict)
|
136
|
+
#update plot_type, since our internal function requires the data series to be added already.
|
137
|
+
if len(plot_type) > 0:
|
138
|
+
newest_record_index = len(self.fig_dict["data"]) - 1
|
139
|
+
self.set_plot_type_one_data_series(newest_record_index, plot_type)
|
140
|
+
|
141
|
+
#this function returns the current record.
|
142
|
+
def get_record(self):
|
143
|
+
"""
|
144
|
+
Returns a JSON-dict string of the record
|
145
|
+
"""
|
146
|
+
return self.fig_dict
|
147
|
+
|
148
|
+
def print_to_inspect(self, update_and_validate=True, validate=True, remove_remaining_hints=False):
|
149
|
+
if remove_remaining_hints == True:
|
150
|
+
self.remove_hints()
|
151
|
+
if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
|
152
|
+
self.update_and_validate_JSONGrapher_record()
|
153
|
+
elif validate: #this will validate without doing automatic updates.
|
154
|
+
self.validate_JSONGrapher_record()
|
155
|
+
print(json.dumps(self.fig_dict, indent=4))
|
156
|
+
|
157
|
+
def populate_from_existing_record(self, existing_JSONGrapher_record):
|
158
|
+
"""
|
159
|
+
Populates attributes from an existing JSONGrapher record.
|
160
|
+
existing_JSONGrapher_record: A dictionary representing an existing JSONGrapher record.
|
161
|
+
"""
|
162
|
+
if "comments" in existing_JSONGrapher_record: self.fig_dict["comments"] = existing_JSONGrapher_record["comments"]
|
163
|
+
if "datatype" in existing_JSONGrapher_record: self.fig_dict["datatype"] = existing_JSONGrapher_record["datatype"]
|
164
|
+
if "data" in existing_JSONGrapher_record: self.fig_dict["data"] = existing_JSONGrapher_record["data"]
|
165
|
+
if "layout" in existing_JSONGrapher_record: self.fig_dict["layout"] = existing_JSONGrapher_record["layout"]
|
166
|
+
|
167
|
+
|
168
|
+
def set_plot_type_one_data_series(self, data_series_index, plot_type):
|
169
|
+
fields_dict = plot_type_to_field_values(plot_type)
|
170
|
+
#get the data_series_dict.
|
171
|
+
data_series_dict = self.fig_dict['data'][data_series_index]
|
172
|
+
#update the data_series_dict.
|
173
|
+
if fields_dict.get("mode_field"):
|
174
|
+
data_series_dict["mode"] = fields_dict["mode_field"]
|
175
|
+
if fields_dict.get("type_field"):
|
176
|
+
data_series_dict["type"] = fields_dict["type_field"]
|
177
|
+
if fields_dict.get("line_shape_field") != "":
|
178
|
+
data_series_dict.setdefault("line", {"shape": ''}) # Creates the field if it does not already exist.
|
179
|
+
data_series_dict["line"]["shape"] = fields_dict["line_shape_field"]
|
180
|
+
|
181
|
+
#now put the data_series_dict back:
|
182
|
+
self.fig_dict['data'][data_series_index] = data_series_dict
|
183
|
+
|
184
|
+
def set_plot_type_all_series(self, plot_type):
|
185
|
+
"""
|
186
|
+
Sets the plot_type field for the all data series.
|
187
|
+
options are: scatter, spline, scatter_spline
|
188
|
+
"""
|
189
|
+
self.plot_type = plot_type
|
190
|
+
for data_series_index in range(len(self.fig_dict['data'])): #works with array indexing.
|
191
|
+
self.set_plot_type_one_data_series(data_series_index, plot_type)
|
192
|
+
|
193
|
+
|
194
|
+
def update_plot_types(self, plot_type=None):
|
195
|
+
"""
|
196
|
+
updates the plot types for any existing data series.
|
197
|
+
|
198
|
+
"""
|
199
|
+
#If optional argument not provided, take class instance setting.
|
200
|
+
if plot_type == None:
|
201
|
+
plot_type = self.plot_type
|
202
|
+
#If the plot_type is not blank, use it for all series.
|
203
|
+
if plot_type != "":
|
204
|
+
self.set_plot_type_all_series(plot_type)
|
205
|
+
|
206
|
+
def set_datatype(self, datatype):
|
207
|
+
"""
|
208
|
+
Sets the datatype field used as the experiment type or schema identifier.
|
209
|
+
datatype (str): The new data type to set.
|
210
|
+
"""
|
211
|
+
self.fig_dict['datatype'] = datatype
|
212
|
+
|
213
|
+
def set_comments(self, comments):
|
214
|
+
"""
|
215
|
+
Updates the comments field for the record.
|
216
|
+
str: The updated comments value.
|
217
|
+
"""
|
218
|
+
self.fig_dict['comments'] = comments
|
219
|
+
|
220
|
+
def set_graph_title(self, graph_title):
|
221
|
+
"""
|
222
|
+
Updates the title of the graph in the layout dictionary.
|
223
|
+
graph_title (str): The new title to set for the graph.
|
224
|
+
"""
|
225
|
+
self.fig_dict['layout']['title'] = graph_title
|
226
|
+
|
227
|
+
def set_x_axis_label_including_units(self, x_axis_label_including_units, remove_plural_units=True):
|
228
|
+
"""
|
229
|
+
Updates the title of the x-axis in the layout dictionary.
|
230
|
+
xaxis_title (str): The new title to set for the x-axis.
|
231
|
+
"""
|
232
|
+
if "xaxis" not in self.fig_dict['layout'] or not isinstance(self.fig_dict['layout'].get("xaxis"), dict):
|
233
|
+
self.fig_dict['layout']["xaxis"] = {} # Initialize x-axis as a dictionary if it doesn't exist.
|
234
|
+
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)
|
235
|
+
self.fig_dict['layout']["xaxis"]["title"] = x_axis_label_including_units
|
236
|
+
|
237
|
+
def set_y_axis_label_including_units(self, y_axis_label_including_units, remove_plural_units=True):
|
238
|
+
"""
|
239
|
+
Updates the title of the y-axis in the layout dictionary.
|
240
|
+
yaxis_title (str): The new title to set for the y-axis.
|
241
|
+
"""
|
242
|
+
if "yaxis" not in self.fig_dict['layout'] or not isinstance(self.fig_dict['layout'].get("yaxis"), dict):
|
243
|
+
self.fig_dict['layout']["yaxis"] = {} # Initialize y-axis as a dictionary if it doesn't exist.
|
244
|
+
|
245
|
+
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)
|
246
|
+
self.fig_dict['layout']["yaxis"]["title"] = y_axis_label_including_units
|
247
|
+
|
248
|
+
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):
|
249
|
+
# comments: General comments about the layout.
|
250
|
+
# graph_title: Title of the graph.
|
251
|
+
# xaxis_title: Title of the x-axis, including units.
|
252
|
+
# xaxis_comments: Comments related to the x-axis.
|
253
|
+
# yaxis_title: Title of the y-axis, including units.
|
254
|
+
# yaxis_comments: Comments related to the y-axis.
|
255
|
+
|
256
|
+
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)
|
257
|
+
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)
|
258
|
+
self.fig_dict['layout'] = {
|
259
|
+
"title": graph_title,
|
260
|
+
"xaxis": {"title": x_axis_label_including_units},
|
261
|
+
"yaxis": {"title": y_axis_label_including_units}
|
262
|
+
}
|
263
|
+
|
264
|
+
#populate any optional fields, if provided:
|
265
|
+
if len(comments) > 0:
|
266
|
+
self.fig_dict['layout']["comments"] = comments
|
267
|
+
if len(x_axis_comments) > 0:
|
268
|
+
self.fig_dict['layout']["xaxis"]["comments"] = x_axis_comments
|
269
|
+
if len(y_axis_comments) > 0:
|
270
|
+
self.fig_dict['layout']["yaxis"]["comments"] = y_axis_comments
|
271
|
+
|
272
|
+
|
273
|
+
return self.fig_dict['layout']
|
274
|
+
|
275
|
+
#TODO: add record validation to this function.
|
276
|
+
def export_to_json_file(self, filename, update_and_validate=True, validate=True, remove_remaining_hints=False):
|
277
|
+
"""
|
278
|
+
writes the json to a file
|
279
|
+
returns the json as a dictionary.
|
280
|
+
optionally removes hints before export and return.
|
281
|
+
"""
|
282
|
+
if remove_remaining_hints == True:
|
283
|
+
self.remove_hints()
|
284
|
+
if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
|
285
|
+
self.update_and_validate_JSONGrapher_record()
|
286
|
+
elif validate: #this will validate without doing automatic updates.
|
287
|
+
self.validate_JSONGrapher_record()
|
288
|
+
|
289
|
+
# filepath: Optional, filename with path to save the JSON file.
|
290
|
+
if len(filename) > 0: #this means we will be writing to file.
|
291
|
+
# Check if the filename has an extension and append `.json` if not
|
292
|
+
if '.json' not in filename.lower():
|
293
|
+
filename += ".json"
|
294
|
+
#Write to file.
|
295
|
+
with open(filename, 'w') as f:
|
296
|
+
json.dump(self.fig_dict, f, indent=4)
|
297
|
+
return self.fig_dict
|
298
|
+
|
299
|
+
def get_matplotlib_fig(self, update_and_validate=True):
|
300
|
+
if update_and_validate == True: #this will do some automatic 'corrections' during the validation.
|
301
|
+
self.update_and_validate_JSONGrapher_record()
|
302
|
+
fig = convert_JSONGrapher_dict_to_matplotlib_fig(self.fig_dict)
|
303
|
+
return fig
|
304
|
+
|
305
|
+
def plot_with_matplotlib(self, update_and_validate=True):
|
306
|
+
import matplotlib.pyplot as plt
|
307
|
+
fig = self.get_matplotlib_fig(update_and_validate=update_and_validate)
|
308
|
+
plt.show()
|
309
|
+
plt.close(fig) #remove fig from memory.
|
310
|
+
|
311
|
+
def export_to_matplotlib_png(self, filename, update_and_validate=True):
|
312
|
+
import matplotlib.pyplot as plt
|
313
|
+
# Ensure filename ends with .png
|
314
|
+
if not filename.lower().endswith(".png"):
|
315
|
+
filename += ".png"
|
316
|
+
fig = self.get_matplotlib_fig(update_and_validate=update_and_validate)
|
317
|
+
# Save the figure to a file
|
318
|
+
fig.savefig(filename)
|
319
|
+
plt.close(fig) #remove fig from memory.
|
320
|
+
|
321
|
+
def add_hints(self):
|
322
|
+
"""
|
323
|
+
Adds hints to fields that are currently empty strings using self.hints_dictionary.
|
324
|
+
Dynamically parses hint keys (e.g., "['layout']['xaxis']['title']") to access and update fields in self.fig_dict.
|
325
|
+
The hints_dictionary is first populated during creation of the class object in __init__.
|
326
|
+
"""
|
327
|
+
for hint_key, hint_text in self.hints_dictionary.items():
|
328
|
+
# Parse the hint_key into a list of keys representing the path in the record.
|
329
|
+
# For example, if hint_key is "['layout']['xaxis']['title']",
|
330
|
+
# then record_path_as_list will be ['layout', 'xaxis', 'title'].
|
331
|
+
record_path_as_list = hint_key.strip("[]").replace("'", "").split("][")
|
332
|
+
record_path_length = len(record_path_as_list)
|
333
|
+
# Start at the top-level record dictionary.
|
334
|
+
current_field = self.fig_dict
|
335
|
+
|
336
|
+
# Loop over each key in the path.
|
337
|
+
# For example, with record_path_as_list = ['layout', 'xaxis', 'title']:
|
338
|
+
# at nesting_level 0, current_path_key will be "layout";
|
339
|
+
# at nesting_level 1, current_path_key will be "xaxis"; <-- (this is the "xaxis" example)
|
340
|
+
# at nesting_level 2, current_path_key will be "title".
|
341
|
+
# Enumerate over keys starting with index 1.
|
342
|
+
for nesting_level, current_path_key in enumerate(record_path_as_list, start=1):
|
343
|
+
# If not the final depth key, then retrieve from deeper.
|
344
|
+
if nesting_level != record_path_length:
|
345
|
+
current_field = current_field.setdefault(current_path_key, {}) # `setdefault` will fill with the second argument if the requested field does not exist.
|
346
|
+
else:
|
347
|
+
# Final key: if the field is empty, set it to hint_text.
|
348
|
+
if current_field.get(current_path_key, "") == "": # `get` will return the second argument if the requested field does not exist.
|
349
|
+
current_field[current_path_key] = hint_text
|
350
|
+
|
351
|
+
def remove_hints(self):
|
352
|
+
"""
|
353
|
+
Removes hints by converting fields back to empty strings if their value matches the hint text in self.hints_dictionary.
|
354
|
+
Dynamically parses hint keys (e.g., "['layout']['xaxis']['title']") to access and update fields in self.fig_dict.
|
355
|
+
The hints_dictionary is first populated during creation of the class object in __init__.
|
356
|
+
"""
|
357
|
+
for hint_key, hint_text in self.hints_dictionary.items():
|
358
|
+
# Parse the hint_key into a list of keys representing the path in the record.
|
359
|
+
# For example, if hint_key is "['layout']['xaxis']['title']",
|
360
|
+
# then record_path_as_list will be ['layout', 'xaxis', 'title'].
|
361
|
+
record_path_as_list = hint_key.strip("[]").replace("'", "").split("][")
|
362
|
+
record_path_length = len(record_path_as_list)
|
363
|
+
# Start at the top-level record dictionary.
|
364
|
+
current_field = self.fig_dict
|
365
|
+
|
366
|
+
# Loop over each key in the path.
|
367
|
+
# For example, with record_path_as_list = ['layout', 'xaxis', 'title']:
|
368
|
+
# at nesting_level 0, current_path_key will be "layout";
|
369
|
+
# at nesting_level 1, current_path_key will be "xaxis"; <-- (this is the "xaxis" example)
|
370
|
+
# at nesting_level 2, current_path_key will be "title".
|
371
|
+
# Enumerate with a starting index of 1.
|
372
|
+
for nesting_level, current_path_key in enumerate(record_path_as_list, start=1):
|
373
|
+
# If not the final depth key, then retrieve from deeper.
|
374
|
+
if nesting_level != record_path_length:
|
375
|
+
current_field = current_field.get(current_path_key, {}) # `get` will return the second argument if the requested field does not exist.
|
376
|
+
else:
|
377
|
+
# Final key: if the field's value equals the hint text, reset it to an empty string.
|
378
|
+
if current_field.get(current_path_key, "") == hint_text:
|
379
|
+
current_field[current_path_key] = ""
|
380
|
+
|
381
|
+
#Make some pointers to external functions, for convenience, so people can use syntax like record.function_name() if desired.
|
382
|
+
def validate_JSONGrapher_record(self):
|
383
|
+
validate_JSONGrapher_record(self)
|
384
|
+
def update_and_validate_JSONGrapher_record(self):
|
385
|
+
update_and_validate_JSONGrapher_record(self)
|
386
|
+
|
387
|
+
# helper function to validate x axis and y axis labels.
|
388
|
+
# label string will be the full label including units. Axis_name is typically "x" or "y"
|
389
|
+
def validate_JSONGrapher_axis_label(label_string, axis_name="", remove_plural_units=True):
|
390
|
+
"""
|
391
|
+
Validates the axis label provided to JSONGrapher.
|
392
|
+
|
393
|
+
Args:
|
394
|
+
label_string (str): The axis label containing a numeric value and units.
|
395
|
+
axis_name (str): The name of the axis being validated (e.g., 'x' or 'y').
|
396
|
+
remove_plural_units (boolean) : Instructions wil to remove plural units or not. Will remove them in the returned stringif set to True, or will simply provide a warning if set to False.
|
397
|
+
|
398
|
+
Returns:
|
399
|
+
None: Prints warnings if any validation issues are found.
|
400
|
+
"""
|
401
|
+
warnings_list = []
|
402
|
+
|
403
|
+
#First check if the label is empty.
|
404
|
+
if label_string == '':
|
405
|
+
warnings_list.append(f"Your {axis_name} axis label is an empty string. JSONGrapher records should not have empty strings for axis labels.")
|
406
|
+
else:
|
407
|
+
parsing_result = separate_label_text_from_units(label_string) # Parse the numeric value and units from the label string
|
408
|
+
# Check if units are missing
|
409
|
+
if parsing_result["units"] == "":
|
410
|
+
warnings_list.append(f"Your {axis_name} axis label is missing units. JSONGrapher is expected to handle axis labels with units, with the units between parentheses '( )'.")
|
411
|
+
# Check if the units string has balanced parentheses
|
412
|
+
open_parens = parsing_result["units"].count("(")
|
413
|
+
close_parens = parsing_result["units"].count(")")
|
414
|
+
if open_parens != close_parens:
|
415
|
+
warnings_list.append(f"Your {axis_name} axis label has unbalanced parentheses in the units. The number of opening parentheses '(' must equal the number of closing parentheses ')'.")
|
416
|
+
|
417
|
+
#now do the plural units check.
|
418
|
+
units_changed_flag, units_singularized = units_plural_removal(parsing_result["units"])
|
419
|
+
if units_changed_flag == True:
|
420
|
+
warnings_list.append("The units of " + parsing_result["units"] + " appear to be plural. Units should be entered as singular, such as 'year' rather than 'years'.")
|
421
|
+
if remove_plural_units==True:
|
422
|
+
label_string = parsing_result["text"] + " (" + units_singularized + ")"
|
423
|
+
warnings_list.append("Now removing the 's' to change the units into singular '" + units_singularized + "'. To avoid this change, use the function you've called with the optional argument of remove_plural_units set to False.")
|
424
|
+
else:
|
425
|
+
pass
|
426
|
+
|
427
|
+
# Return validation result
|
428
|
+
if warnings_list:
|
429
|
+
print(f"Warning: Your {axis_name} axis label did not pass expected vaidation checks. You may use Record.set_x_axis_label() or Record.set_y_axis_label() to change the labels. The validity check fail messages are as follows: \n", warnings_list)
|
430
|
+
return False, warnings_list, label_string
|
431
|
+
else:
|
432
|
+
return True, [], label_string
|
433
|
+
|
434
|
+
def units_plural_removal(units_to_check):
|
435
|
+
"""
|
436
|
+
Parses a units string to remove "s" if the string is found as an exact match without an s in the units lists.
|
437
|
+
Args:
|
438
|
+
units_to_check (str): A string containing units to check.
|
439
|
+
|
440
|
+
Returns:
|
441
|
+
tuple: A tuple of two values
|
442
|
+
- "changed" (Boolean): True, or False, where True means the string was changed to remove an "s" at the end.
|
443
|
+
- "singularized" (string): The units parsed to be singular, if needed.
|
444
|
+
"""
|
445
|
+
#first check if we have the module we need. If not, return with no change.
|
446
|
+
|
447
|
+
try:
|
448
|
+
import JSONGrapher.units_list as units_list
|
449
|
+
except:
|
450
|
+
units_changed_flag = False
|
451
|
+
return units_changed_flag, units_to_check #return None if there was no test.
|
452
|
+
#First try to check if units are blank or ends with "s" is in the units list.
|
453
|
+
|
454
|
+
if (units_to_check == "") or (units_to_check[-1] != "s"):
|
455
|
+
units_changed_flag = False
|
456
|
+
units_singularized = units_to_check #return if string is blank or does not end with s.
|
457
|
+
elif (units_to_check != "") and (units_to_check[-1] == "s"): #continue if not blank and ends with s.
|
458
|
+
if (units_to_check in units_list.expanded_ids_set) or (units_to_check in units_list.expanded_names_set):#return unchanged if unit is recognized.
|
459
|
+
units_changed_flag = False
|
460
|
+
units_singularized = units_to_check #No change if was found.
|
461
|
+
else:
|
462
|
+
truncated_string = units_to_check[0:-1] #remove last letter.
|
463
|
+
if (truncated_string in units_list.expanded_ids_set) or (truncated_string in units_list.expanded_names_set):
|
464
|
+
units_changed_flag = True
|
465
|
+
units_singularized = truncated_string #return without the s.
|
466
|
+
else: #No change if the truncated string isn't found.
|
467
|
+
units_changed_flag = False
|
468
|
+
units_singularized = units_to_check
|
469
|
+
return units_changed_flag, units_singularized
|
470
|
+
|
471
|
+
|
472
|
+
def separate_label_text_from_units(label_with_units):
|
473
|
+
"""
|
474
|
+
Parses a label with text string and units in parentheses after that to return the two parts.
|
475
|
+
|
476
|
+
Args:
|
477
|
+
value (str): A string containing a label and optional units enclosed in parentheses.
|
478
|
+
Example: "Time (Years)" or "Speed (km/s)
|
479
|
+
|
480
|
+
Returns:
|
481
|
+
dict: A dictionary with two keys:
|
482
|
+
- "text" (str): The label text parsed from the input string.
|
483
|
+
- "units" (str): The units parsed from the input string, or an empty string if no units are present.
|
484
|
+
"""
|
485
|
+
# Find the position of the first '(' and the last ')'
|
486
|
+
start = label_with_units.find('(')
|
487
|
+
end = label_with_units.rfind(')')
|
488
|
+
|
489
|
+
# Ensure both are found and properly ordered
|
490
|
+
if start != -1 and end != -1 and end > start:
|
491
|
+
text_part = label_with_units[:start].strip() # Everything before '('
|
492
|
+
units_part = label_with_units[start + 1:end].strip() # Everything inside '()'
|
493
|
+
else:
|
494
|
+
text_part = label_with_units
|
495
|
+
units_part = ""
|
496
|
+
parsed_output = {
|
497
|
+
"text":text_part,
|
498
|
+
"units":units_part
|
499
|
+
}
|
500
|
+
return parsed_output
|
501
|
+
|
502
|
+
|
503
|
+
def validate_plotly_data_list(data):
|
504
|
+
"""
|
505
|
+
Validates the entries in a Plotly data array.
|
506
|
+
If a dictionary is received, the function will assume you are sending in a single dataseries for validation
|
507
|
+
and will put it in a list of one before the validation.
|
508
|
+
|
509
|
+
Args:
|
510
|
+
data (list): A list of dictionaries, each representing a Plotly trace.
|
511
|
+
|
512
|
+
Returns:
|
513
|
+
bool: True if all entries are valid, False otherwise.
|
514
|
+
list: A list of errors describing why the validation failed.
|
515
|
+
"""
|
516
|
+
#check if a dictionary was received. If so, will assume that
|
517
|
+
#a single series has been sent, and will put it in a list by itself.
|
518
|
+
if type(data) == type({}):
|
519
|
+
data = [data]
|
520
|
+
|
521
|
+
required_fields_by_type = {
|
522
|
+
"scatter": ["x", "y"],
|
523
|
+
"bar": ["x", "y"],
|
524
|
+
"pie": ["labels", "values"],
|
525
|
+
"heatmap": ["z"],
|
526
|
+
}
|
527
|
+
|
528
|
+
warnings_list = []
|
529
|
+
|
530
|
+
for i, trace in enumerate(data):
|
531
|
+
if not isinstance(trace, dict):
|
532
|
+
warnings_list.append(f"Trace {i} is not a dictionary.")
|
533
|
+
continue
|
534
|
+
|
535
|
+
# Determine the type based on the fields provided
|
536
|
+
trace_type = trace.get("type")
|
537
|
+
if not trace_type:
|
538
|
+
# Infer type based on fields and attributes
|
539
|
+
if "x" in trace and "y" in trace:
|
540
|
+
if "mode" in trace or "marker" in trace or "line" in trace:
|
541
|
+
trace_type = "scatter"
|
542
|
+
elif "text" in trace or "marker.color" in trace:
|
543
|
+
trace_type = "bar"
|
544
|
+
else:
|
545
|
+
trace_type = "scatter" # Default assumption
|
546
|
+
elif "labels" in trace and "values" in trace:
|
547
|
+
trace_type = "pie"
|
548
|
+
elif "z" in trace:
|
549
|
+
trace_type = "heatmap"
|
550
|
+
else:
|
551
|
+
warnings_list.append(f"Trace {i} cannot be inferred as a valid type.")
|
552
|
+
continue
|
553
|
+
|
554
|
+
# Check for required fields
|
555
|
+
required_fields = required_fields_by_type.get(trace_type, [])
|
556
|
+
for field in required_fields:
|
557
|
+
if field not in trace:
|
558
|
+
warnings_list.append(f"Trace {i} (type inferred as {trace_type}) is missing required field: {field}.")
|
559
|
+
|
560
|
+
if warnings_list:
|
561
|
+
print("Warning: There are some entries in your data list that did not pass validation checks: \n", warnings_list)
|
562
|
+
return False, warnings_list
|
563
|
+
else:
|
564
|
+
return True, []
|
565
|
+
|
566
|
+
def parse_units(value):
|
567
|
+
"""
|
568
|
+
Parses a numerical value and its associated units from a string. This meant for scientific constants and parameters
|
569
|
+
Such as rate constants, gravitational constant, or simiilar.
|
570
|
+
|
571
|
+
Args:
|
572
|
+
value (str): A string containing a numeric value and optional units enclosed in parentheses.
|
573
|
+
Example: "42 (kg)" or "100".
|
574
|
+
|
575
|
+
Returns:
|
576
|
+
dict: A dictionary with two keys:
|
577
|
+
- "value" (float): The numeric value parsed from the input string.
|
578
|
+
- "units" (str): The units parsed from the input string, or an empty string if no units are present.
|
579
|
+
"""
|
580
|
+
# Find the position of the first '(' and the last ')'
|
581
|
+
start = value.find('(')
|
582
|
+
end = value.rfind(')')
|
583
|
+
|
584
|
+
# Ensure both are found and properly ordered
|
585
|
+
if start != -1 and end != -1 and end > start:
|
586
|
+
number_part = value[:start].strip() # Everything before '('
|
587
|
+
units_part = value[start + 1:end].strip() # Everything inside '()'
|
588
|
+
parsed_output = {
|
589
|
+
"value": float(number_part), # Convert number part to float
|
590
|
+
"units": units_part # Extracted units
|
591
|
+
}
|
592
|
+
else:
|
593
|
+
parsed_output = {
|
594
|
+
"value": float(value), # No parentheses, assume the entire string is numeric
|
595
|
+
"units": "" # Empty string represents absence of units
|
596
|
+
}
|
597
|
+
|
598
|
+
return parsed_output
|
599
|
+
|
600
|
+
def plot_type_to_field_values(plot_type):
|
601
|
+
"""
|
602
|
+
Takes in a string that is a plot type, such as "scatter", "scatter_spline", etc.
|
603
|
+
and returns the field values that would have to go into a plotly data object.
|
604
|
+
|
605
|
+
Returns:
|
606
|
+
dict: A dictionary with keys and values for the fields that will be ultimately filled.
|
607
|
+
|
608
|
+
To these fields are used in the function set_plot_type_one_data_series
|
609
|
+
|
610
|
+
"""
|
611
|
+
|
612
|
+
fields_dict = {}
|
613
|
+
#initialize some variables.
|
614
|
+
fields_dict["type_field"] = plot_type
|
615
|
+
fields_dict["mode_field"] = None
|
616
|
+
fields_dict["line_shape_field"] = None
|
617
|
+
# Assign the various types. This list of values was determined 'manually'.
|
618
|
+
if plot_type == "scatter":
|
619
|
+
fields_dict["type_field"] = "scatter"
|
620
|
+
fields_dict["mode_field"] = "markers"
|
621
|
+
fields_dict["line_shape_field"] = None
|
622
|
+
elif plot_type == "scatter_spline":
|
623
|
+
fields_dict["type_field"] = "scatter"
|
624
|
+
fields_dict["mode_field"] = None
|
625
|
+
fields_dict["line_shape_field"] = "spline"
|
626
|
+
elif plot_type == "spline":
|
627
|
+
fields_dict["type_field"] = None
|
628
|
+
fields_dict["mode_field"] = 'lines'
|
629
|
+
fields_dict["line_shape_field"] = "spline"
|
630
|
+
return fields_dict
|
631
|
+
|
632
|
+
#This function does updating of internal things before validating
|
633
|
+
#This is used before printing and returning the JSON record.
|
634
|
+
def update_and_validate_JSONGrapher_record(record):
|
635
|
+
record.update_plot_types()
|
636
|
+
record.validate_JSONGrapher_record()
|
637
|
+
|
638
|
+
#TODO: add the ability for this function to check against the schema.
|
639
|
+
def validate_JSONGrapher_record(record):
|
640
|
+
"""
|
641
|
+
Validates a JSONGrapher record to ensure all required fields are present and correctly structured.
|
642
|
+
|
643
|
+
Args:
|
644
|
+
record (dict): The JSONGrapher record to validate.
|
645
|
+
|
646
|
+
Returns:
|
647
|
+
bool: True if the record is valid, False otherwise.
|
648
|
+
list: A list of errors describing any validation issues.
|
649
|
+
"""
|
650
|
+
warnings_list = []
|
651
|
+
|
652
|
+
# Check top-level fields
|
653
|
+
if not isinstance(record, dict):
|
654
|
+
return False, ["The record is not a dictionary."]
|
655
|
+
|
656
|
+
# Validate "comments"
|
657
|
+
if "comments" not in record:
|
658
|
+
warnings_list.append("Missing top-level 'comments' field.")
|
659
|
+
elif not isinstance(record["comments"], str):
|
660
|
+
warnings_list.append("'comments' is a recommended field and should be a string with a description and/or metadata of the record, and citation references may also be included.")
|
661
|
+
|
662
|
+
# Validate "datatype"
|
663
|
+
if "datatype" not in record:
|
664
|
+
warnings_list.append("Missing 'datatype' field.")
|
665
|
+
elif not isinstance(record["datatype"], str):
|
666
|
+
warnings_list.append("'datatype' should be a string.")
|
667
|
+
|
668
|
+
# Validate "data"
|
669
|
+
if "data" not in record:
|
670
|
+
warnings_list.append("Missing top-level 'data' field.")
|
671
|
+
elif not isinstance(record["data"], list):
|
672
|
+
warnings_list.append("'data' should be a list.")
|
673
|
+
validate_plotly_data_list(record["data"]) #No need to append warnings, they will print within that function.
|
674
|
+
|
675
|
+
# Validate "layout"
|
676
|
+
if "layout" not in record:
|
677
|
+
warnings_list.append("Missing top-level 'layout' field.")
|
678
|
+
elif not isinstance(record["layout"], dict):
|
679
|
+
warnings_list.append("'layout' should be a dictionary.")
|
680
|
+
else:
|
681
|
+
# Validate "layout" subfields
|
682
|
+
layout = record["layout"]
|
683
|
+
|
684
|
+
# Validate "title"
|
685
|
+
if "title" not in layout:
|
686
|
+
warnings_list.append("Missing 'layout.title' field.")
|
687
|
+
elif not isinstance(layout["title"], str):
|
688
|
+
warnings_list.append("'layout.title' should be a string.")
|
689
|
+
|
690
|
+
# Validate "xaxis"
|
691
|
+
if "xaxis" not in layout:
|
692
|
+
warnings_list.append("Missing 'layout.xaxis' field.")
|
693
|
+
elif not isinstance(layout["xaxis"], dict):
|
694
|
+
warnings_list.append("'layout.xaxis' should be a dictionary.")
|
695
|
+
else:
|
696
|
+
# Validate "xaxis.title"
|
697
|
+
if "title" not in layout["xaxis"]:
|
698
|
+
warnings_list.append("Missing 'layout.xaxis.title' field.")
|
699
|
+
elif not isinstance(layout["xaxis"]["title"], str):
|
700
|
+
warnings_list.append("'layout.xaxis.title' should be a string.")
|
701
|
+
|
702
|
+
# Validate "yaxis"
|
703
|
+
if "yaxis" not in layout:
|
704
|
+
warnings_list.append("Missing 'layout.yaxis' field.")
|
705
|
+
elif not isinstance(layout["yaxis"], dict):
|
706
|
+
warnings_list.append("'layout.yaxis' should be a dictionary.")
|
707
|
+
else:
|
708
|
+
# Validate "yaxis.title"
|
709
|
+
if "title" not in layout["yaxis"]:
|
710
|
+
warnings_list.append("Missing 'layout.yaxis.title' field.")
|
711
|
+
elif not isinstance(layout["yaxis"]["title"], str):
|
712
|
+
warnings_list.append("'layout.yaxis.title' should be a string.")
|
713
|
+
|
714
|
+
# Return validation result
|
715
|
+
if warnings_list:
|
716
|
+
print("Warning: There are missing fields in your JSONGrapher record: \n", warnings_list)
|
717
|
+
return False, warnings_list
|
718
|
+
else:
|
719
|
+
return True, []
|
720
|
+
|
721
|
+
def rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2):
|
722
|
+
"""
|
723
|
+
Applies a rolling polynomial regression with a specified window size and degree.
|
724
|
+
|
725
|
+
Args:
|
726
|
+
x_values (list): List of x coordinates.
|
727
|
+
y_values (list): List of y coordinates.
|
728
|
+
window_size (int): Number of points per rolling fit (default: 3).
|
729
|
+
degree (int): Degree of polynomial to fit (default: 2).
|
730
|
+
|
731
|
+
Returns:
|
732
|
+
tuple: (smoothed_x, smoothed_y) lists for plotting.
|
733
|
+
"""
|
734
|
+
import numpy as np
|
735
|
+
smoothed_y = []
|
736
|
+
smoothed_x = x_values # Keep x values unchanged
|
737
|
+
|
738
|
+
half_window = window_size // 2 # Number of points to take before & after
|
739
|
+
|
740
|
+
for i in range(len(y_values)):
|
741
|
+
# Handle edge cases: First and last points have fewer neighbors
|
742
|
+
left_bound = max(0, i - half_window)
|
743
|
+
right_bound = min(len(y_values), i + half_window + 1)
|
744
|
+
|
745
|
+
# Select the windowed data
|
746
|
+
x_window = np.array(x_values[left_bound:right_bound])
|
747
|
+
y_window = np.array(y_values[left_bound:right_bound])
|
748
|
+
|
749
|
+
# Fit polynomial & evaluate at current point
|
750
|
+
poly_coeffs = np.polyfit(x_window, y_window, deg=degree)
|
751
|
+
smoothed_y.append(np.polyval(poly_coeffs, x_values[i]))
|
752
|
+
|
753
|
+
return smoothed_x, smoothed_y
|
754
|
+
|
755
|
+
|
756
|
+
def convert_JSONGrapher_dict_to_matplotlib_fig(fig_dict):
|
757
|
+
"""
|
758
|
+
Converts a Plotly figure dictionary into a Matplotlib figure without using pio.from_json.
|
759
|
+
|
760
|
+
Args:
|
761
|
+
fig_dict (dict): A dictionary representing a Plotly figure.
|
762
|
+
|
763
|
+
Returns:
|
764
|
+
matplotlib.figure.Figure: The corresponding Matplotlib figure.
|
765
|
+
"""
|
766
|
+
import matplotlib.pyplot as plt
|
767
|
+
fig, ax = plt.subplots()
|
768
|
+
|
769
|
+
# Extract traces (data series)
|
770
|
+
for trace in fig_dict.get("data", []):
|
771
|
+
trace_type = trace.get("type", None)
|
772
|
+
# If type is missing, but mode indicates lines and shape is spline, assume it's a spline
|
773
|
+
if not trace_type and trace.get("mode") == "lines" and trace.get("line", {}).get("shape") == "spline":
|
774
|
+
trace_type = "spline"
|
775
|
+
|
776
|
+
x_values = trace.get("x", [])
|
777
|
+
y_values = trace.get("y", [])
|
778
|
+
trace_name = trace.get("name", "Data")
|
779
|
+
if trace_type == "bar":
|
780
|
+
ax.bar(x_values, y_values, label=trace_name)
|
781
|
+
|
782
|
+
elif trace_type == "scatter":
|
783
|
+
mode = trace.get("mode", "")
|
784
|
+
ax.scatter(x_values, y_values, label=trace_name, alpha=0.7)
|
785
|
+
|
786
|
+
# Attempt to simulate spline behavior if requested
|
787
|
+
if "lines" in mode or trace.get("line", {}).get("shape") == "spline":
|
788
|
+
print("Warning: Rolling polynomial approximation used instead of spline.")
|
789
|
+
x_smooth, y_smooth = rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2)
|
790
|
+
|
791
|
+
# Add a label explicitly for the legend
|
792
|
+
ax.plot(x_smooth, y_smooth, linestyle="-", label=f"{trace_name} Spline")
|
793
|
+
elif trace_type == "spline":
|
794
|
+
print("Warning: Using rolling polynomial approximation instead of true spline.")
|
795
|
+
x_smooth, y_smooth = rolling_polynomial_fit(x_values, y_values, window_size=3, degree=2)
|
796
|
+
ax.plot(x_smooth, y_smooth, linestyle="-", label=f"{trace_name} Spline")
|
797
|
+
|
798
|
+
# Extract layout details
|
799
|
+
layout = fig_dict.get("layout", {})
|
800
|
+
title = layout.get("title", {})
|
801
|
+
if isinstance(title, dict):
|
802
|
+
ax.set_title(title.get("text", "Converted Plotly Figure"))
|
803
|
+
else:
|
804
|
+
ax.set_title(title if isinstance(title, str) else "Converted Plotly Figure")
|
805
|
+
|
806
|
+
xaxis = layout.get("xaxis", {})
|
807
|
+
xlabel = "X-Axis" # Default label
|
808
|
+
if isinstance(xaxis, dict):
|
809
|
+
title_obj = xaxis.get("title", {})
|
810
|
+
xlabel = title_obj.get("text", "X-Axis") if isinstance(title_obj, dict) else title_obj
|
811
|
+
elif isinstance(xaxis, str):
|
812
|
+
xlabel = xaxis # If it's a string, use it directly
|
813
|
+
ax.set_xlabel(xlabel)
|
814
|
+
yaxis = layout.get("yaxis", {})
|
815
|
+
ylabel = "Y-Axis" # Default label
|
816
|
+
if isinstance(yaxis, dict):
|
817
|
+
title_obj = yaxis.get("title", {})
|
818
|
+
ylabel = title_obj.get("text", "Y-Axis") if isinstance(title_obj, dict) else title_obj
|
819
|
+
elif isinstance(yaxis, str):
|
820
|
+
ylabel = yaxis # If it's a string, use it directly
|
821
|
+
ax.set_ylabel(ylabel)
|
822
|
+
ax.legend()
|
823
|
+
return fig
|
824
|
+
|
825
|
+
#The below function works, but because it depends on the python plotly package, we avoid using it
|
826
|
+
#To decrease the number of dependencies.
|
827
|
+
def convert_plotly_dict_to_matplotlib(fig_dict):
|
828
|
+
"""
|
829
|
+
Converts a Plotly figure dictionary into a Matplotlib figure.
|
830
|
+
|
831
|
+
Supports: Bar Charts, Scatter Plots, Spline curves using rolling polynomial regression.
|
832
|
+
|
833
|
+
This functiony has a dependency on the plotly python package (pip install plotly)
|
834
|
+
|
835
|
+
Args:
|
836
|
+
fig_dict (dict): A dictionary representing a Plotly figure.
|
837
|
+
|
838
|
+
Returns:
|
839
|
+
matplotlib.figure.Figure: The corresponding Matplotlib figure.
|
840
|
+
"""
|
841
|
+
import plotly.io as pio
|
842
|
+
|
843
|
+
# Convert JSON dictionary into a Plotly figure
|
844
|
+
plotly_fig = pio.from_json(json.dumps(fig_dict))
|
845
|
+
|
846
|
+
# Create a Matplotlib figure
|
847
|
+
fig, ax = plt.subplots()
|
848
|
+
|
849
|
+
for trace in plotly_fig.data:
|
850
|
+
if trace.type == "bar":
|
851
|
+
ax.bar(trace.x, trace.y, label=trace.name if trace.name else "Bar Data")
|
852
|
+
|
853
|
+
elif trace.type == "scatter":
|
854
|
+
mode = trace.mode if isinstance(trace.mode, str) else ""
|
855
|
+
line_shape = trace.line["shape"] if hasattr(trace, "line") and "shape" in trace.line else None
|
856
|
+
|
857
|
+
# Plot raw scatter points
|
858
|
+
ax.scatter(trace.x, trace.y, label=trace.name if trace.name else "Scatter Data", alpha=0.7)
|
859
|
+
|
860
|
+
# If spline is requested, apply rolling polynomial smoothing
|
861
|
+
if line_shape == "spline" or "lines" in mode:
|
862
|
+
print("Warning: During the matploglib conversion, a rolling polynomial will be used instead of a spline, whereas JSONGrapher uses a true spline.")
|
863
|
+
x_smooth, y_smooth = rolling_polynomial_fit(trace.x, trace.y, window_size=3, degree=2)
|
864
|
+
ax.plot(x_smooth, y_smooth, linestyle="-", label=trace.name + " Spline" if trace.name else "Spline Curve")
|
865
|
+
|
866
|
+
ax.legend()
|
867
|
+
ax.set_title(plotly_fig.layout.title.text if plotly_fig.layout.title else "Converted Plotly Figure")
|
868
|
+
ax.set_xlabel(plotly_fig.layout.xaxis.title.text if plotly_fig.layout.xaxis.title else "X-Axis")
|
869
|
+
ax.set_ylabel(plotly_fig.layout.yaxis.title.text if plotly_fig.layout.yaxis.title else "Y-Axis")
|
870
|
+
|
871
|
+
return fig
|
872
|
+
|
873
|
+
# Example Usage
|
874
|
+
if __name__ == "__main__":
|
875
|
+
# Example of creating a record with optional attributes.
|
876
|
+
record = JSONGrapherRecord(
|
877
|
+
comments="Here is a description.",
|
878
|
+
graph_title="Graph Title",
|
879
|
+
data_objects_list=[
|
880
|
+
{"comments": "Initial data series.", "uid": "123", "line": {"shape": "solid"}, "name": "Series A", "type": "line", "x": [1, 2, 3], "y": [4, 5, 6]}
|
881
|
+
],
|
882
|
+
)
|
883
|
+
|
884
|
+
# Example of creating a record from an existing dictionary.
|
885
|
+
existing_JSONGrapher_record = {
|
886
|
+
"comments": "Existing record description.",
|
887
|
+
"graph_title": "Existing Graph",
|
888
|
+
"data": [
|
889
|
+
{"comments": "Data series 1", "uid": "123", "line": {"shape": "solid"}, "name": "Series A", "type": "line", "x": [1, 2, 3], "y": [4, 5, 6]}
|
890
|
+
],
|
891
|
+
}
|
892
|
+
record_from_existing = JSONGrapherRecord(existing_JSONGrapher_record=existing_JSONGrapher_record)
|
893
|
+
record.export_to_json_file("test.json")
|
894
|
+
print(record)
|