mirror of
https://github.com/Relintai/pmlpp_sample.git
synced 2024-12-21 13:47:52 +01:00
504 lines
16 KiB
GDScript
504 lines
16 KiB
GDScript
extends Control
|
|
class_name Chart
|
|
|
|
var x: Array
|
|
var y: Array
|
|
|
|
var x_min_max: Pair = Pair.new() # Min and Max values of @x
|
|
var x_domain: Pair = Pair.new() # Rounded domain of values of @x
|
|
var y_min_max: Pair = Pair.new() # Min and Max values of @y
|
|
var y_domain: Pair = Pair.new() # Rounded domain of values of @x
|
|
|
|
var x_sampled: SampledAxis = SampledAxis.new()
|
|
var y_sampled: SampledAxis = SampledAxis.new()
|
|
|
|
var x_labels: Array = []
|
|
var y_labels: Array = []
|
|
var functions_names: Array = []
|
|
|
|
###### STYLE
|
|
var chart_properties: ChartProperties = ChartProperties.new()
|
|
|
|
#### INTERNAL
|
|
# The bounding_box of the chart
|
|
var node_box: Rect2
|
|
var bounding_box: Rect2
|
|
var plot_offset: Vector2
|
|
var plot_box: Rect2
|
|
|
|
# The Reference Rectangle to plot samples
|
|
# It is the @bounding_box Rectangle inverted on the Y axis
|
|
var x_sampled_domain: Pair
|
|
var y_sampled_domain: Pair
|
|
|
|
var _padding_offset: Vector2 = Vector2(20.0, 20.0)
|
|
var _internal_offset: Vector2 = Vector2(15.0, 15.0)
|
|
|
|
var y_has_decimals: bool
|
|
var _y_label_size: Vector2 = Vector2.ZERO # offset only on the X axis
|
|
var _y_label_offset: int = 15 # offset only on the X axis
|
|
var _y_ticklabel_size: Vector2 # offset only on the X axis
|
|
var _y_ticklabel_offset: int = 5 # offset only on the X axis
|
|
var _y_tick_size: int = 7
|
|
|
|
var x_has_decimals: bool
|
|
var _x_label_size: Vector2 = Vector2.ZERO # offset only on the X axis
|
|
var _x_label_offset: int = 15 # offset only on the X axis
|
|
var _x_ticklabel_size: Vector2 # offset only on the X axis
|
|
var _x_ticklabel_offset: int = 5 # offset only on the X axis
|
|
var _x_tick_size: int = 7
|
|
|
|
###########
|
|
func _ready() -> void:
|
|
set_process_input(false)
|
|
set_process(false)
|
|
|
|
func validate_input_samples(samples: Array) -> bool:
|
|
if samples.size() > 1 and samples[0] is Array:
|
|
for sample in samples:
|
|
if (not sample is Array) or sample.size() != samples[0].size():
|
|
return false
|
|
return true
|
|
|
|
func plot(x: Array, y: Array, properties: ChartProperties = self.chart_properties) -> void:
|
|
self.x = x
|
|
self.y = y
|
|
|
|
if properties != null:
|
|
self.chart_properties = properties
|
|
|
|
set_process_input(chart_properties.interactive)
|
|
|
|
update()
|
|
|
|
func _map_pair(val: float, rel: Pair, ref: Pair) -> float:
|
|
return range_lerp(val, rel.left, rel.right, ref.left, ref.right)
|
|
|
|
func _has_decimals(values: Array) -> bool:
|
|
var temp: Array = values.duplicate(true)
|
|
|
|
if temp[0] is Array:
|
|
for dim in temp:
|
|
for val in dim:
|
|
if val is String:
|
|
return false
|
|
if abs(fmod(val, 1)) > 0.0:
|
|
return true
|
|
else:
|
|
for val in temp:
|
|
if val is String:
|
|
return false
|
|
if abs(fmod(val, 1)) > 0.0:
|
|
return true
|
|
|
|
return false
|
|
|
|
func _find_min_max(values: Array) -> Pair:
|
|
var temp: Array = values.duplicate(true)
|
|
var _min: float
|
|
var _max: float
|
|
|
|
if temp[0] is Array:
|
|
var min_ts: Array
|
|
var max_ts: Array
|
|
for dim in temp:
|
|
min_ts.append(dim.min())
|
|
max_ts.append(dim.max())
|
|
_min = min_ts.min()
|
|
_max = max_ts.max()
|
|
else:
|
|
_min = temp.min()
|
|
_max = temp.max()
|
|
|
|
return Pair.new(_min, _max)
|
|
|
|
func _sample_values(values: Array, rel_values: Pair, ref_values: Pair) -> SampledAxis:
|
|
if values.empty():
|
|
printerr("Trying to plot an empty dataset!")
|
|
return SampledAxis.new()
|
|
|
|
if values[0] is Array:
|
|
if values.size() > 1:
|
|
for dim in values:
|
|
if values[0].size() != dim.size():
|
|
printerr("Cannot plot a dataset with dimensions of different size!")
|
|
return SampledAxis.new()
|
|
|
|
var temp: Array = values.duplicate(true)
|
|
|
|
var rels: Array = []
|
|
var division_size: float
|
|
if temp[0] is Array:
|
|
for t_dim in temp:
|
|
var rels_t: Array = []
|
|
for val in t_dim:
|
|
rels_t.append(_map_pair(val, rel_values, ref_values))
|
|
rels.append(rels_t)
|
|
|
|
else:
|
|
division_size = (ref_values.right - ref_values.left) / values.size()
|
|
for val_i in temp.size():
|
|
if temp[val_i] is String:
|
|
rels.append(val_i * division_size)
|
|
else:
|
|
rels.append(_map_pair(temp[val_i], rel_values, ref_values))
|
|
|
|
return SampledAxis.new(rels, rel_values)
|
|
|
|
func _round_min(val: float) -> float:
|
|
return round(val) if abs(val) < 10 else floor(val / 10.0) * 10.0
|
|
|
|
func _round_max(val: float) -> float:
|
|
return round(val) if abs(val) < 10 else ceil(val / 10.0) * 10.0
|
|
|
|
|
|
func _calc_x_domain() -> void:
|
|
x_min_max = _find_min_max(x)
|
|
x_domain = Pair.new(_round_min(x_min_max.left), _round_max(x_min_max.right))
|
|
|
|
func _sample_x() -> void:
|
|
### @sampled_domain, which are the domain relative to the sampled values
|
|
### x (real value) --> sampling --> x_sampled (pixel value in canvas)
|
|
x_sampled_domain = Pair.new(plot_box.position.x, plot_box.end.x)
|
|
|
|
# samples
|
|
x_sampled = _sample_values(x, x_min_max, x_sampled_domain)
|
|
|
|
func _calc_y_domain() -> void:
|
|
y_min_max = _find_min_max(y)
|
|
y_domain = Pair.new(_round_min(y_min_max.left), _round_max(y_min_max.right))
|
|
|
|
func _sample_y() -> void:
|
|
### @sampled_domain, which are the domain relative to the sampled values
|
|
### x (real value) --> sampling --> x_sampled (pixel value in canvas)
|
|
y_sampled_domain = Pair.new(plot_box.end.y, plot_box.position.y)
|
|
|
|
# samples
|
|
y_sampled = _sample_values(y, y_domain, y_sampled_domain)
|
|
|
|
|
|
func _find_longest_x() -> String:
|
|
return ("%.2f" if x_has_decimals else "%s") % x_domain.right
|
|
|
|
func _pre_process() -> void:
|
|
_calc_x_domain()
|
|
_calc_y_domain()
|
|
|
|
var frame: Rect2 = get_global_rect()
|
|
|
|
|
|
#### @node_box size, which is the whole "frame"
|
|
node_box = Rect2(Vector2.ZERO, frame.size - frame.position)
|
|
|
|
#### calculating offset from the @node_box for the @bounding_box.
|
|
plot_offset = _padding_offset
|
|
|
|
### if @labels drawing is enabled, calcualte offsets
|
|
if chart_properties.labels:
|
|
### labels (X, Y, Title)
|
|
_x_label_size = chart_properties.font.get_string_size(chart_properties.x_label)
|
|
_y_label_size = chart_properties.font.get_string_size(chart_properties.y_label)
|
|
|
|
### tick labels
|
|
|
|
###### --- X
|
|
x_has_decimals = _has_decimals(x)
|
|
# calculate the string length of the largest value on the X axis.
|
|
# remember that "-" sign adds additional pixels, and it is relative only to negative numbers!
|
|
var x_max_formatted: String = _find_longest_x()
|
|
_x_ticklabel_size = chart_properties.font.get_string_size(x_max_formatted)
|
|
|
|
plot_offset.y += _x_label_offset + _x_label_size.y + _x_ticklabel_offset + _x_ticklabel_size.y
|
|
|
|
###### --- Y
|
|
y_has_decimals = _has_decimals(y)
|
|
# calculate the string length of the largest value on the Y axis.
|
|
# remember that "-" sign adds additional pixels, and it is relative only to negative numbers!
|
|
var y_max_formatted: String = ("%.2f" if y_has_decimals else "%s") % y_domain.right
|
|
if y_domain.left < 0:
|
|
# negative number
|
|
var y_min_formatted: String = ("%.2f" if y_has_decimals else "%s") % y_domain.left
|
|
if y_min_formatted.length() >= y_max_formatted.length():
|
|
_y_ticklabel_size = chart_properties.font.get_string_size(y_min_formatted)
|
|
else:
|
|
_y_ticklabel_size = chart_properties.font.get_string_size(y_max_formatted)
|
|
else:
|
|
_y_ticklabel_size = chart_properties.font.get_string_size(y_max_formatted)
|
|
|
|
plot_offset.x += _y_label_offset + _y_label_size.y + _y_ticklabel_offset + _y_ticklabel_size.x
|
|
|
|
### if @ticks drawing is enabled, calculate offsets
|
|
if chart_properties.ticks:
|
|
plot_offset.x += _y_tick_size
|
|
plot_offset.y += _x_tick_size
|
|
|
|
### @bounding_box, where the points will be plotted
|
|
bounding_box = Rect2(
|
|
plot_offset,
|
|
frame.size - (plot_offset * 2)
|
|
)
|
|
|
|
plot_box = Rect2(
|
|
bounding_box.position + _internal_offset,
|
|
bounding_box.size - (_internal_offset * 2)
|
|
)
|
|
|
|
_sample_x()
|
|
_sample_y()
|
|
|
|
func _draw_borders() -> void:
|
|
draw_rect(node_box, Color.red, false, 1, true)
|
|
|
|
func _draw_bounding_box() -> void:
|
|
draw_rect(bounding_box, chart_properties.colors.bounding_box, false, 1, true)
|
|
|
|
# # (debug)
|
|
# var half: Vector2 = (bounding_box.size) / 2
|
|
# draw_line(bounding_box.position + Vector2(half.x, 0), bounding_box.position + Vector2(half.x, bounding_box.size.y), Color.red, 3, false)
|
|
# draw_line(bounding_box.position + Vector2(0, half.y), bounding_box.position + Vector2(bounding_box.size.x, half.y), Color.red, 3, false)
|
|
|
|
func _draw_origin() -> void:
|
|
var xorigin: float = _map_pair(0.0, x_min_max, x_sampled_domain)
|
|
var yorigin: float = _map_pair(0.0, y_domain, y_sampled_domain)
|
|
draw_line(Vector2(xorigin, bounding_box.position.y), Vector2(xorigin, bounding_box.position.y + bounding_box.size.y), Color.black, 1, 0)
|
|
draw_line(Vector2(bounding_box.position.x, yorigin), Vector2(bounding_box.position.x + bounding_box.size.x, yorigin), Color.black, 1, 0)
|
|
draw_string(chart_properties.font, Vector2(xorigin, yorigin) - Vector2(15, -15), "O", chart_properties.colors.bounding_box)
|
|
|
|
func _draw_background() -> void:
|
|
draw_rect(node_box, Color.white, true, 1.0, false)
|
|
|
|
# # (debug)
|
|
# var half: Vector2 = node_box.size / 2
|
|
# draw_line(Vector2(half.x, node_box.position.y), Vector2(half.x, node_box.size.y), Color.red, 3, false)
|
|
# draw_line(Vector2(node_box.position.x, half.y), Vector2(node_box.size.x, half.y), Color.red, 3, false)
|
|
|
|
func _draw_tick(from: Vector2, to: Vector2, color: Color) -> void:
|
|
draw_line(from, to, color, 1, true)
|
|
|
|
|
|
func _get_vertical_tick_label_pos(base_position: Vector2, text: String) -> Vector2:
|
|
return base_position + Vector2(
|
|
- chart_properties.font.get_string_size(text).x / 2,
|
|
_x_label_size.y + _x_tick_size
|
|
)
|
|
|
|
func _get_tick_label(line_index: int, line_value: float) -> String:
|
|
var tick_lbl: String = ""
|
|
if x_labels.empty():
|
|
tick_lbl = ("%.2f" if x_has_decimals else "%s") % [x_min_max.left + (line_index * line_value)]
|
|
else:
|
|
tick_lbl = x_labels[clamp(line_value * line_index, 0, x_labels.size() - 1)]
|
|
|
|
return tick_lbl
|
|
|
|
func _draw_vertical_gridline_component(p1: Vector2, p2: Vector2, line_index: int, line_value: float) -> void:
|
|
if chart_properties.labels:
|
|
var tick_lbl: String = _get_tick_label(line_index, line_value)
|
|
draw_string(
|
|
chart_properties.font,
|
|
_get_vertical_tick_label_pos(p2, tick_lbl),
|
|
tick_lbl,
|
|
chart_properties.colors.bounding_box
|
|
)
|
|
|
|
# Draw V Ticks
|
|
if chart_properties.ticks:
|
|
_draw_tick(p2, p2 + Vector2(0, _x_tick_size), chart_properties.colors.bounding_box)
|
|
|
|
# Draw V Grid Lines
|
|
if chart_properties.grid:
|
|
draw_line(p1, p2, chart_properties.colors.grid, 1, true)
|
|
|
|
|
|
func _draw_horizontal_tick_label(font: Font, position: Vector2, color: Color, line_index: int, line_value: float) -> void:
|
|
var tick_lbl: String = ""
|
|
if y_labels.empty():
|
|
tick_lbl = ("%.2f" if y_has_decimals else "%s") % [y_domain.left + (line_index * line_value)]
|
|
else:
|
|
tick_lbl = y_labels[clamp(y_labels.size() * line_index, 0, y_labels.size() - 1)]
|
|
|
|
draw_string(
|
|
chart_properties.font,
|
|
position - Vector2(
|
|
chart_properties.font.get_string_size(tick_lbl).x + _y_ticklabel_offset + _y_tick_size,
|
|
- _y_ticklabel_size.y * 0.35
|
|
),
|
|
tick_lbl,
|
|
chart_properties.colors.bounding_box
|
|
)
|
|
|
|
|
|
func _draw_horizontal_gridline_component(p1: Vector2, p2: Vector2, line_index: int, line_value: float) -> void:
|
|
# Draw H labels
|
|
if chart_properties.labels:
|
|
_draw_horizontal_tick_label(
|
|
chart_properties.font,
|
|
p1,
|
|
chart_properties.colors.bounding_box,
|
|
line_index,
|
|
line_value
|
|
)
|
|
|
|
# Draw H Ticks
|
|
if chart_properties.ticks:
|
|
_draw_tick(p1, p1 - Vector2(_y_tick_size, 0), chart_properties.colors.bounding_box)
|
|
|
|
# Draw H Grid Lines
|
|
if chart_properties.grid:
|
|
draw_line(p1, p2, chart_properties.colors.grid, 1, true)
|
|
|
|
func _draw_vertical_grid() -> void:
|
|
# draw vertical lines
|
|
|
|
# 1. the amount of lines is equals to the X_scale: it identifies in how many sectors the x domain
|
|
# should be devided
|
|
# 2. calculate the spacing between each line in pixel. It is equals to x_sampled_domain / x_scale
|
|
# 3. calculate the offset in the real x domain, which is x_domain / x_scale.
|
|
var x_pixel_dist: float = (x_sampled.min_max.right - x_sampled.min_max.left) / (chart_properties.x_scale)
|
|
var x_lbl_val: float = (x_min_max.right - x_min_max.left) / (chart_properties.x_scale)
|
|
for _x in chart_properties.x_scale + 1:
|
|
var x_val: float = _x * x_pixel_dist + x_sampled.min_max.left
|
|
var top: Vector2 = Vector2(
|
|
range_lerp(x_val, x_sampled.min_max.left, x_sampled.min_max.right, x_sampled_domain.left, x_sampled_domain.right),
|
|
bounding_box.position.y
|
|
)
|
|
var bottom: Vector2 = Vector2(
|
|
range_lerp(x_val, x_sampled.min_max.left, x_sampled.min_max.right, x_sampled_domain.left, x_sampled_domain.right),
|
|
bounding_box.size.y + bounding_box.position.y
|
|
)
|
|
|
|
_draw_vertical_gridline_component(top, bottom, _x, x_lbl_val)
|
|
|
|
func _draw_horizontal_grid() -> void:
|
|
# 1. the amount of lines is equals to the y_scale: it identifies in how many sectors the y domain
|
|
# should be devided
|
|
# 2. calculate the spacing between each line in pixel. It is equals to y_sampled_domain / y_scale
|
|
# 3. calculate the offset in the real y domain, which is y_domain / y_scale.
|
|
var y_pixel_dist: float = (y_sampled.min_max.right - y_sampled.min_max.left) / (chart_properties.y_scale)
|
|
var y_lbl_val: float = (y_domain.right - y_domain.left) / (chart_properties.y_scale)
|
|
for _y in chart_properties.y_scale + 1:
|
|
var y_val: float = (_y * y_pixel_dist) + y_sampled.min_max.left
|
|
var left: Vector2 = Vector2(
|
|
bounding_box.position.x,
|
|
range_lerp(y_val, y_sampled.min_max.left, y_sampled.min_max.right, y_sampled_domain.left, y_sampled_domain.right)
|
|
)
|
|
var right: Vector2 = Vector2(
|
|
bounding_box.size.x + bounding_box.position.x,
|
|
range_lerp(y_val, y_sampled.min_max.left, y_sampled.min_max.right, y_sampled_domain.left, y_sampled_domain.right)
|
|
)
|
|
|
|
_draw_horizontal_gridline_component(left, right, _y, y_lbl_val)
|
|
|
|
func _draw_grid() -> void:
|
|
var validation: int = _validate_sampled_axis(x_sampled, y_sampled)
|
|
if not validation == OK:
|
|
printerr("Cannot draw grid for invalid dataset! Error: %s" % validation)
|
|
return
|
|
|
|
_draw_vertical_grid()
|
|
_draw_horizontal_grid()
|
|
|
|
func _create_canvas_label(text: String, position: Vector2, rotation: float = 0.0) -> Label:
|
|
var lbl: Label = Label.new()
|
|
$Canvas.add_child(lbl)
|
|
lbl.set("custom_fonts/font", chart_properties.font)
|
|
lbl.set_text(text)
|
|
lbl.modulate = chart_properties.colors.bounding_box
|
|
lbl.rect_rotation = rotation
|
|
lbl.rect_position = position
|
|
return lbl
|
|
|
|
func _update_canvas_label(canvas_label: Label, text: String, position: Vector2, rotation: float = 0.0) -> void:
|
|
canvas_label.set_text(text)
|
|
canvas_label.modulate = chart_properties.colors.bounding_box
|
|
canvas_label.rect_rotation = rotation
|
|
canvas_label.rect_position = position
|
|
|
|
func _draw_yaxis_label() -> void:
|
|
_update_canvas_label(
|
|
$Canvas/YLabel,
|
|
chart_properties.y_label,
|
|
Vector2(_padding_offset.x, (node_box.size.y / 2) + (_y_label_size.x / 2)),
|
|
-90
|
|
)
|
|
|
|
func _draw_xaxis_label() -> void:
|
|
_update_canvas_label(
|
|
$Canvas/XLabel,
|
|
chart_properties.x_label,
|
|
Vector2(
|
|
node_box.size.x/2 - (_x_label_size.x / 2),
|
|
node_box.size.y - _padding_offset.y - _x_label_size.y
|
|
)
|
|
)
|
|
|
|
func _draw_title() -> void:
|
|
_update_canvas_label(
|
|
$Canvas/Title,
|
|
chart_properties.title,
|
|
Vector2(node_box.size.x / 2, _padding_offset.y*2) - (chart_properties.font.get_string_size(chart_properties.title) / 2)
|
|
)
|
|
|
|
func _clear_canvas_labels() -> void:
|
|
for label in $Canvas.get_children():
|
|
label.queue_free()
|
|
|
|
func _clear() -> void:
|
|
_clear_canvas_labels()
|
|
|
|
# Draw Loop:
|
|
# the drow loop gives order to what thigs will be drawn
|
|
# each chart specifies its own draw loop that inherits from this one.
|
|
# The draw loop also contains the "processing loop" which is where
|
|
# everything is calculated in a separated function.
|
|
func _draw():
|
|
if not (validate_input_samples(x) and validate_input_samples(y)):
|
|
printerr("Input samples are invalid!")
|
|
return
|
|
|
|
_clear()
|
|
_pre_process()
|
|
|
|
if chart_properties.background:
|
|
_draw_background()
|
|
|
|
if chart_properties.borders:
|
|
_draw_borders()
|
|
|
|
if chart_properties.grid or chart_properties.ticks or chart_properties.labels:
|
|
_draw_grid()
|
|
|
|
if chart_properties.bounding_box:
|
|
_draw_bounding_box()
|
|
|
|
if chart_properties.origin:
|
|
_draw_origin()
|
|
|
|
if chart_properties.labels:
|
|
_draw_xaxis_label()
|
|
_draw_yaxis_label()
|
|
_draw_title()
|
|
|
|
func _validate_sampled_axis(x_data: SampledAxis, y_data: SampledAxis) -> int:
|
|
var error: int = 0 # OK
|
|
if x_data.values.empty() or y_data.values.empty():
|
|
# Either there are no X or Y
|
|
error = 1
|
|
elif y_data.values[0] is Array:
|
|
for dim in y_data.values:
|
|
if dim.size() != x_data.values.size():
|
|
error = 3 # one of Y dim has not X length
|
|
break
|
|
else:
|
|
if y_data.values.size() != x_data.values.size():
|
|
# X and Y samples don't have same length
|
|
error = 2
|
|
return error
|
|
|
|
# ----- utilities
|
|
func _get_function_name(function_idx: int) -> String:
|
|
return functions_names[function_idx] if functions_names.size() > 0 else "Function %s" % function_idx
|
|
|
|
func _get_function_color(function_idx: int) -> Color:
|
|
return chart_properties.colors.functions[function_idx] if chart_properties.colors.functions.size() > 0 else Color.black
|